diff options
| author | 2024-01-17 22:14:31 -0800 | |
|---|---|---|
| committer | 2024-01-17 22:14:31 -0800 | |
| commit | efee97bcc526928fb7168072e0305f5a72324fbc (patch) | |
| tree | 7edfc23366f90cdca5852209a6ac207b7de884a4 /java/src | |
| parent | 3e303554182e65402022ecd079d63b94ce80ffe4 (diff) | |
| parent | 3007d9f481e92ed57ca9e3783719b3d84797ef2c (diff) | |
Merge Android 24Q1 Release (ab/11220357)
Bug: 319669529
Merged-In: I95e383e2822917198425acf9ba8bfbea76fdf948
Change-Id: Ibd7bfe1c21d32e1d0cc3023971afb779ed14c3a9
Diffstat (limited to 'java/src')
126 files changed, 10870 insertions, 1487 deletions
diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java index 168f36d6..3565e757 100644 --- a/java/src/com/android/intentresolver/AnnotatedUserHandles.java +++ b/java/src/com/android/intentresolver/AnnotatedUserHandles.java @@ -16,12 +16,12 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; import android.os.UserHandle; import android.os.UserManager; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; /** @@ -35,7 +35,7 @@ public final class AnnotatedUserHandles { /** * The {@link UserHandle} that launched Sharesheet. * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp} - * except possibly if the caller used {@link Activity#startActivityAsUser()} to launch + * except possibly if the caller used {@link Activity#startActivityAsUser} to launch * Sharesheet as a different user than they themselves were running as. Verify and document. */ public final UserHandle userHandleSharesheetLaunchedAs; @@ -57,21 +57,21 @@ public final class AnnotatedUserHandles { /** * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary) - * one of the "managed" profiles associated with {@link personalProfileUserHandle}. + * one of the "managed" profiles associated with {@link #personalProfileUserHandle}. */ @Nullable public final UserHandle workProfileUserHandle; /** - * The {@link UserHandle} of the clone profile belonging to {@link personalProfileUserHandle}. + * The {@link UserHandle} of the clone profile belonging to {@link #personalProfileUserHandle}. */ @Nullable public final UserHandle cloneProfileUserHandle; /** - * The "tab owner" user handle (i.e., either {@link personalProfileUserHandle} or - * {@link workProfileUserHandle}) that either matches or owns the profile of the - * {@link userHandleSharesheetLaunchedAs}. + * The "tab owner" user handle (i.e., either {@link #personalProfileUserHandle} or + * {@link #workProfileUserHandle}) that either matches or owns the profile of the + * {@link #userHandleSharesheetLaunchedAs}. * * In the current implementation, we can assert that this is the same as * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is @@ -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/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index a54e8c62..310fcc27 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityOptions; import android.app.PendingIntent; @@ -34,6 +33,8 @@ import android.text.TextUtils; import android.util.Log; import android.view.View; +import androidx.annotation.Nullable; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; @@ -98,12 +99,11 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final @Nullable ChooserAction mModifyShareAction; private final Consumer<Boolean> mExcludeSharedTextAction; private final Consumer</* @Nullable */ Integer> mFinishCallback; - private final EventLog mLogger; + private final EventLog mLog; /** * @param context * @param chooserRequest data about the invocation of the current Sharesheet session. - * @param integratedDeviceComponents info about other components that are available on this * device to implement the supported action types. * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" * setting is updated. The argument is whether the shared text is to be excluded. @@ -117,7 +117,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Context context, ChooserRequestParameters chooserRequest, ChooserIntegratedDeviceComponents integratedDeviceComponents, - EventLog logger, + EventLog log, Consumer<Boolean> onUpdateSharedTextIsExcluded, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, @@ -129,7 +129,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio chooserRequest.getTargetIntent(), chooserRequest.getReferrerPackageName(), finishCallback, - logger), + log), makeEditButtonRunnable( getEditSharingTarget( context, @@ -137,11 +137,11 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio integratedDeviceComponents), firstVisibleImageQuery, activityStarter, - logger), + log), chooserRequest.getChooserActions(), chooserRequest.getModifyShareAction(), onUpdateSharedTextIsExcluded, - logger, + log, finishCallback); } @@ -153,7 +153,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio List<ChooserAction> customActions, @Nullable ChooserAction modifyShareAction, Consumer<Boolean> onUpdateSharedTextIsExcluded, - EventLog logger, + EventLog log, Consumer</* @Nullable */ Integer> finishCallback) { mContext = context; mCopyButtonRunnable = copyButtonRunnable; @@ -161,7 +161,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mCustomActions = ImmutableList.copyOf(customActions); mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; - mLogger = logger; + mLog = log; mFinishCallback = finishCallback; } @@ -188,7 +188,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mCustomActions.get(i), mFinishCallback, () -> { - mLogger.logCustomActionSelected(position); + mLog.logCustomActionSelected(position); } ); if (actionRow != null) { @@ -209,7 +209,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mModifyShareAction, mFinishCallback, () -> { - mLogger.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); + mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); }); } @@ -233,13 +233,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Intent targetIntent, String referrerPackageName, Consumer<Integer> finishCallback, - EventLog logger) { + EventLog log) { final ClipData clipData; try { clipData = extractTextToCopy(targetIntent); } catch (Throwable t) { Log.e(TAG, "Failed to extract data to copy", t); - return null; + return null; } if (clipData == null) { return null; @@ -249,7 +249,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Context.CLIPBOARD_SERVICE); clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); - logger.logActionSelected(EventLog.SELECTION_TYPE_COPY); + log.logActionSelected(EventLog.SELECTION_TYPE_COPY); finishCallback.accept(Activity.RESULT_OK); }; } @@ -317,8 +317,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ri, context.getString(R.string.screenshot_edit), "", - resolveIntent, - null); + resolveIntent); dri.getDisplayIconHolder().setDisplayIcon( context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); return dri; @@ -328,10 +327,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio TargetInfo editSharingTarget, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, - EventLog logger) { + EventLog log) { return () -> { // Log share completion via edit. - logger.logActionSelected(EventLog.SELECTION_TYPE_EDIT); + log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); View firstImageView = null; try { @@ -373,10 +372,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio null, null, ActivityOptions.makeCustomAnimation( - context, - R.anim.slide_in_right, - R.anim.slide_out_left) - .toBundle()); + context, + R.anim.slide_in_right, + R.anim.slide_out_left) + .toBundle()); } catch (PendingIntent.CanceledException e) { Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); } diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index b27f054e..9000ab3a 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -24,10 +24,10 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROS 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.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; @@ -51,11 +51,9 @@ import android.database.Cursor; import android.graphics.Insets; import android.net.Uri; import android.os.Bundle; -import android.os.Environment; import android.os.SystemClock; import android.os.UserHandle; import android.os.UserManager; -import android.os.storage.StorageManager; import android.service.chooser.ChooserTarget; import android.util.Log; import android.util.Slog; @@ -67,15 +65,15 @@ import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.widget.TextView; +import androidx.annotation.IntDef; import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -83,8 +81,10 @@ 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.flags.FeatureFlagRepository; -import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; +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; @@ -100,7 +100,8 @@ import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import java.io.File; +import dagger.hilt.android.AndroidEntryPoint; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.text.Collator; @@ -115,12 +116,15 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; +import javax.inject.Inject; + /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. * */ -public class ChooserActivity extends ResolverActivity implements +@AndroidEntryPoint(ResolverActivity.class) +public class ChooserActivity extends Hilt_ChooserActivity implements ResolverListAdapter.ResolverListCommunicator { private static final String TAG = "ChooserActivity"; @@ -161,7 +165,7 @@ public class ChooserActivity extends ResolverActivity implements 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 = { + @IntDef({ TARGET_TYPE_DEFAULT, TARGET_TYPE_CHOOSER_TARGET, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, @@ -170,6 +174,9 @@ public class ChooserActivity extends ResolverActivity implements @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 @@ -183,13 +190,9 @@ public class ChooserActivity extends ResolverActivity implements private ChooserRefinementManager mRefinementManager; - private FeatureFlagRepository mFeatureFlagRepository; private ChooserContentPreviewUi mChooserContentPreviewUi; private boolean mShouldDisplayLandscape; - // statsd logger wrapper - protected EventLog mEventLog; - private long mChooserShownTime; protected boolean mIsSuccessfullySelected; @@ -229,31 +232,52 @@ public class ChooserActivity extends ResolverActivity implements */ private boolean mFinishWhenStopped = false; - public ChooserActivity() {} - @Override protected void onCreate(Bundle savedInstanceState) { Tracer.INSTANCE.markLaunched(); final long intentReceivedTime = System.currentTimeMillis(); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); - getEventLog().logSharesheetTriggered(); - - mFeatureFlagRepository = createFeatureFlagRepository(); - mIntegratedDeviceComponents = getIntegratedDeviceComponents(); - try { mChooserRequest = new ChooserRequestParameters( getIntent(), getReferrerPackageName(), - getReferrer(), - mFeatureFlagRepository); + 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); @@ -279,39 +303,21 @@ public class ChooserActivity extends ResolverActivity implements new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); mChooserContentPreviewUi = new ChooserContentPreviewUi( - getLifecycle(), - previewViewModel.createOrReuseProvider(mChooserRequest), + getCoroutineScope(getLifecycle()), + previewViewModel.createOrReuseProvider(mChooserRequest.getTargetIntent()), mChooserRequest.getTargetIntent(), previewViewModel.createOrReuseImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this)); - 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( - getApplicationContext(), - 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); + updateStickyContentPreview(); + if (shouldShowStickyContentPreview() + || mChooserMultiProfilePagerAdapter + .getCurrentRootAdapter().getSystemRowCount() != 0) { + getEventLog().logActionShareWithPreview( + mChooserContentPreviewUi.getPreferredContentPreview()); + } mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - intentReceivedTime; @@ -358,19 +364,15 @@ public class ChooserActivity extends ResolverActivity implements return R.style.Theme_DeviceDefault_Chooser; } - protected FeatureFlagRepository createFeatureFlagRepository() { - return new FeatureFlagRepositoryFactory().create(getApplicationContext()); - } - private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = getPersonalProfileUserHandle(); + UserHandle mainUserHandle = getAnnotatedUserHandles().personalProfileUserHandle; ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); if (record.shortcutLoader == null) { Tracer.INSTANCE.endLaunchToShortcutTrace(); } - UserHandle workUserHandle = getWorkProfileUserHandle(); + UserHandle workUserHandle = getAnnotatedUserHandles().workProfileUserHandle; if (workUserHandle != null) { createProfileRecord(workUserHandle, targetIntentFilter, factory); } @@ -382,7 +384,7 @@ public class ChooserActivity extends ResolverActivity implements ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() ? null : createShortcutLoader( - getApplicationContext(), + this, appPredictor, userHandle, targetIntentFilter, @@ -406,7 +408,7 @@ public class ChooserActivity extends ResolverActivity implements Consumer<ShortcutLoader.Result> callback) { return new ShortcutLoader( context, - getLifecycle(), + getCoroutineScope(getLifecycle()), appPredictor, userHandle, targetIntentFilter, @@ -414,23 +416,11 @@ public class ChooserActivity extends ResolverActivity implements } static SharedPreferences getPinnedSharedPrefs(Context context) { - // The code below is because in the android:ui process, no one can hear you scream. - // The package info in the context isn't initialized in the way it is for normal apps, - // so the standard, name-based context.getSharedPreferences doesn't work. Instead, we - // build the path manually below using the same policy that appears in ContextImpl. - // This fails silently under the hood if there's a problem, so if we find ourselves in - // the case where we don't have access to credential encrypted storage we just won't - // have our pinned target info. - final File prefsFile = new File(new File( - Environment.getDataUserCePackageDirectory(StorageManager.UUID_PRIVATE_INTERNAL, - context.getUserId(), context.getPackageName()), - "shared_prefs"), - PINNED_SHARED_PREFS_NAME + ".xml"); - return context.getSharedPreferences(prefsFile, MODE_PRIVATE); + return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); } @Override - protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( + protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List<ResolveInfo> rList, boolean filterLastUsed, @@ -475,9 +465,12 @@ public class ChooserActivity extends ResolverActivity implements /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); - return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), - noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch()); + return new NoCrossProfileEmptyStateProvider( + getAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( @@ -491,7 +484,7 @@ public class ChooserActivity extends ResolverActivity implements initialIntents, rList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle(), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, @@ -499,8 +492,9 @@ public class ChooserActivity extends ResolverActivity implements createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, - getCloneProfileUserHandle(), - mMaxTargetsPerRow); + getAnnotatedUserHandles().cloneProfileUserHandle, + mMaxTargetsPerRow, + mFeatureFlags); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( @@ -515,7 +509,7 @@ public class ChooserActivity extends ResolverActivity implements selectedProfile == PROFILE_PERSONAL ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle(), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); ChooserGridAdapter workAdapter = createChooserGridAdapter( /* context */ this, @@ -523,40 +517,30 @@ public class ChooserActivity extends ResolverActivity implements selectedProfile == PROFILE_WORK ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ getWorkProfileUserHandle(), + /* userHandle */ getAnnotatedUserHandles().workProfileUserHandle, targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, personalAdapter, workAdapter, - createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()), + createEmptyStateProvider(getAnnotatedUserHandles().workProfileUserHandle), () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, - getWorkProfileUserHandle(), - getCloneProfileUserHandle(), - mMaxTargetsPerRow); + getAnnotatedUserHandles().workProfileUserHandle, + getAnnotatedUserHandles().cloneProfileUserHandle, + mMaxTargetsPerRow, + mFeatureFlags); } private int findSelectedProfile() { int selectedProfile = getSelectedProfileExtra(); if (selectedProfile == -1) { - selectedProfile = getProfileForUser(getTabOwnerUserHandleForLaunch()); + selectedProfile = getProfileForUser( + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } return selectedProfile; } - @Override - protected boolean postRebuildList(boolean rebuildCompleted) { - updateStickyContentPreview(); - if (shouldShowStickyContentPreview() - || mChooserMultiProfilePagerAdapter - .getCurrentRootAdapter().getSystemRowCount() != 0) { - getEventLog().logActionShareWithPreview( - mChooserContentPreviewUi.getPreferredContentPreview()); - } - return postRebuildListInternal(rebuildCompleted); - } - /** * 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 @@ -621,7 +605,7 @@ public class ChooserActivity extends ResolverActivity implements } @Override - public void onConfigurationChanged(Configuration newConfig) { + public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); if (viewPager.isLayoutRtl()) { @@ -686,7 +670,10 @@ public class ChooserActivity extends ResolverActivity implements ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( getResources(), getLayoutInflater(), - parent); + parent, + mFeatureFlags.scrollablePreview() + ? findViewById(R.id.chooser_headline_row_container) + : null); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); @@ -807,7 +794,9 @@ public class ChooserActivity extends ResolverActivity implements @Override public int getLayoutResource() { - return R.layout.chooser_grid; + return mFeatureFlags.scrollablePreview() + ? R.layout.chooser_grid_scrollable_preview + : R.layout.chooser_grid; } @Override // ResolverListCommunicator @@ -1030,7 +1019,7 @@ public class ChooserActivity extends ResolverActivity implements mIsSuccessfullySelected = true; } - private void maybeRemoveSharedText(@androidx.annotation.NonNull TargetInfo targetInfo) { + private void maybeRemoveSharedText(@NonNull TargetInfo targetInfo) { Intent targetIntent = targetInfo.getTargetIntent(); if (targetIntent == null) { return; @@ -1105,7 +1094,8 @@ public class ChooserActivity extends ResolverActivity implements 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 || getCloneProfileUserHandle() != null) ? null : record.appPredictor; + return ((record == null) || (getAnnotatedUserHandles().cloneProfileUserHandle != null)) + ? null : record.appPredictor; } /** @@ -1130,9 +1120,6 @@ public class ChooserActivity extends ResolverActivity implements } protected EventLog getEventLog() { - if (mEventLog == null) { - mEventLog = new EventLog(); - } return mEventLog; } @@ -1156,7 +1143,7 @@ public class ChooserActivity extends ResolverActivity implements } @Override - boolean isComponentFiltered(ComponentName name) { + public boolean isComponentFiltered(ComponentName name) { return mChooserRequest.getFilteredComponentNames().contains(name); } @@ -1184,7 +1171,7 @@ public class ChooserActivity extends ResolverActivity implements createListController(userHandle), userHandle, getTargetIntent(), - mChooserRequest, + mChooserRequest.getReferrerFillInIntent(), mMaxTargetsPerRow, targetDataLoader); @@ -1229,7 +1216,8 @@ public class ChooserActivity extends ResolverActivity implements }, chooserListAdapter, shouldShowContentPreview(), - mMaxTargetsPerRow); + mMaxTargetsPerRow, + mFeatureFlags); } @VisibleForTesting @@ -1242,12 +1230,12 @@ public class ChooserActivity extends ResolverActivity implements ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, - ChooserRequestParameters chooserRequest, + Intent referrerFillInIntent, int maxTargetsPerRow, TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getPersonalProfileUserHandle()) - ? getCloneProfileUserHandle() : userHandle; + && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; return new ChooserListAdapter( context, payloadIntents, @@ -1257,18 +1245,19 @@ public class ChooserActivity extends ResolverActivity implements createListController(userHandle), userHandle, targetIntent, + referrerFillInIntent, this, context.getPackageManager(), getEventLog(), - chooserRequest, maxTargetsPerRow, initialIntentsUserSpace, - targetDataLoader); + targetDataLoader, + null); } @Override protected void onWorkProfileStatusUpdated() { - UserHandle workUser = getWorkProfileUserHandle(); + UserHandle workUser = getAnnotatedUserHandles().workProfileUserHandle; ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); @@ -1323,7 +1312,8 @@ public class ChooserActivity extends ResolverActivity implements new ChooserActionFactory.ActionActivityStarter() { @Override public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { - safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle()); + safelyStartActivityAsUser( + targetInfo, getAnnotatedUserHandles().personalProfileUserHandle); finish(); } @@ -1333,11 +1323,12 @@ public class ChooserActivity extends ResolverActivity implements ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( ChooserActivity.this, sharedElement, sharedElementName); safelyStartActivityAsUser( - targetInfo, getPersonalProfileUserHandle(), options.toBundle()); + targetInfo, + getAnnotatedUserHandles().personalProfileUserHandle, + options.toBundle()); // Can't finish right away because the shared element transition may not // be ready to start. mFinishWhenStopped = true; - } }, (status) -> { @@ -1490,7 +1481,7 @@ public class ChooserActivity extends ResolverActivity implements * Returns {@link #PROFILE_PERSONAL}, otherwise. **/ private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(getWorkProfileUserHandle())) { + if (currentUserHandle.equals(getAnnotatedUserHandles().workProfileUserHandle)) { return PROFILE_WORK; } // We return personal profile, as it is the default when there is no work profile, personal @@ -1510,19 +1501,21 @@ public class ChooserActivity extends ResolverActivity implements } @Override - public void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { + protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); maybeSetupGlobalLayoutListener(); ChooserListAdapter chooserListAdapter = (ChooserListAdapter) listAdapter; - if (chooserListAdapter.getUserHandle() - .equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { + 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 { @@ -1530,25 +1523,28 @@ public class ChooserActivity extends ResolverActivity implements } if (rebuildComplete) { - long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listAdapter.getUserHandle()); + long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listProfileUserHandle); if (duration >= 0) { Log.d(TAG, "app target loading time " + duration + " ms"); } addCallerChooserTargets(); getEventLog().logSharesheetAppLoadComplete(); - maybeQueryAdditionalPostProcessingTargets(chooserListAdapter); + maybeQueryAdditionalPostProcessingTargets( + listProfileUserHandle, + chooserListAdapter.getDisplayResolveInfos()); mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET); } } - private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) { - UserHandle userHandle = chooserListAdapter.getUserHandle(); + 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(chooserListAdapter.getDisplayResolveInfos()); + record.shortcutLoader.updateAppTargets(displayResolveInfos); } @MainThread @@ -1596,7 +1592,8 @@ public class ChooserActivity extends ResolverActivity implements getResources().getDimensionPixelSize(R.dimen.chooser_header_scroll_elevation); mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( new RecyclerView.OnScrollListener() { - public void onScrollStateChanged(RecyclerView view, int scrollState) { + @Override + public void onScrollStateChanged(@NonNull RecyclerView view, int scrollState) { if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { mScrollStatus = SCROLL_STATUS_IDLE; @@ -1610,7 +1607,8 @@ public class ChooserActivity extends ResolverActivity implements } } - public void onScrolled(RecyclerView view, int dx, int dy) { + @Override + public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { if (view.getChildCount() > 0) { View child = view.getLayoutManager().findViewByPosition(0); if (child == null || child.getTop() < 0) { @@ -1656,11 +1654,13 @@ public class ChooserActivity extends ResolverActivity implements } private boolean shouldShowStickyContentPreviewNoOrientationCheck() { - return shouldShowTabs() - && (mMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())).getCount() > 0 - || shouldShowContentPreviewWhenEmpty()) - && shouldShowContentPreview(); + if (!shouldShowContentPreview()) { + return false; + } + boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())).getCount() == 0; + return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) + && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } /** 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..7cd86bf4 100644 --- a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java +++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java @@ -16,12 +16,13 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.content.ComponentName; import android.content.Context; import android.provider.Settings; import android.text.TextUtils; +import androidx.annotation.Nullable; + import com.android.internal.annotations.VisibleForTesting; /** @@ -50,7 +51,8 @@ public class ChooserIntegratedDeviceComponents { @VisibleForTesting ChooserIntegratedDeviceComponents( - ComponentName editSharingComponent, ComponentName nearbySharingComponent) { + @Nullable ComponentName editSharingComponent, + @Nullable 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 e6d6dbf4..876ad5c3 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -19,7 +19,6 @@ package com.android.intentresolver; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; -import android.annotation.Nullable; import android.app.ActivityManager; import android.app.prediction.AppTarget; import android.content.ComponentName; @@ -38,11 +37,16 @@ import android.os.UserManager; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; import android.text.Layout; +import android.text.TextUtils; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; @@ -57,10 +61,23 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.concurrent.Executor; import java.util.stream.Collectors; public class ChooserListAdapter extends ResolverListAdapter { + + /** + * Delegate interface for injecting a chooser-specific operation to be performed before handling + * a package-change event. This allows the "driver" invoking the package-change to be generic, + * with no knowledge specific to the chooser implementation. + */ + public interface PackageChangeCallback { + /** Perform any steps necessary before processing the package-change event. */ + void beforeHandlingPackagesChanged(); + } + private static final String TAG = "ChooserListAdapter"; private static final boolean DEBUG = false; @@ -78,13 +95,17 @@ public class ChooserListAdapter extends ResolverListAdapter { /** {@link #getBaseScore} */ public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f; - private final ChooserRequestParameters mChooserRequest; + private final Intent mReferrerFillInIntent; + private final int mMaxRankedTargets; private final EventLog mEventLog; private final Set<TargetInfo> mRequestedIcons = new HashSet<>(); + @Nullable + private final PackageChangeCallback mPackageChangeCallback; + // Reserve spots for incoming direct share targets by adding placeholders private final TargetInfo mPlaceHolderTargetInfo; private final TargetDataLoader mTargetDataLoader; @@ -94,7 +115,7 @@ public class ChooserListAdapter extends ResolverListAdapter { private final ShortcutSelectionLogic mShortcutSelectionLogic; // Sorted list of DisplayResolveInfos for the alphabetical app section. - private List<DisplayResolveInfo> mSortedList = new ArrayList<>(); + private final List<DisplayResolveInfo> mSortedList = new ArrayList<>(); private final ItemRevealAnimationTracker mAnimationTracker = new ItemRevealAnimationTracker(); @@ -138,13 +159,55 @@ public class ChooserListAdapter extends ResolverListAdapter { ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, + Intent referrerFillInIntent, + ResolverListCommunicator resolverListCommunicator, + PackageManager packageManager, + EventLog eventLog, + int maxRankedTargets, + UserHandle initialIntentsUserSpace, + TargetDataLoader targetDataLoader, + @Nullable PackageChangeCallback packageChangeCallback) { + this( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + referrerFillInIntent, + resolverListCommunicator, + packageManager, + eventLog, + maxRankedTargets, + initialIntentsUserSpace, + targetDataLoader, + packageChangeCallback, + AsyncTask.SERIAL_EXECUTOR, + context.getMainExecutor()); + } + + @VisibleForTesting + public ChooserListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + Intent referrerFillInIntent, ResolverListCommunicator resolverListCommunicator, PackageManager packageManager, EventLog eventLog, - ChooserRequestParameters chooserRequest, int maxRankedTargets, UserHandle initialIntentsUserSpace, - TargetDataLoader targetDataLoader) { + TargetDataLoader targetDataLoader, + @Nullable PackageChangeCallback packageChangeCallback, + Executor bgExecutor, + Executor mainExecutor) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super( @@ -158,13 +221,16 @@ public class ChooserListAdapter extends ResolverListAdapter { targetIntent, resolverListCommunicator, initialIntentsUserSpace, - targetDataLoader); + targetDataLoader, + bgExecutor, + mainExecutor); - mChooserRequest = chooserRequest; mMaxRankedTargets = maxRankedTargets; + mReferrerFillInIntent = referrerFillInIntent; mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); mTargetDataLoader = targetDataLoader; + mPackageChangeCallback = packageChangeCallback; createPlaceHolders(); mEventLog = eventLog; mShortcutSelectionLogic = new ShortcutSelectionLogic( @@ -227,9 +293,8 @@ public class ChooserListAdapter extends ResolverListAdapter { ri.icon = 0; } ri.userHandle = initialIntentsUserSpace; - // TODO: remove DisplayResolveInfo dependency on presentation getter - DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( - ii, ri, ii, mTargetDataLoader.createPresentationGetter(ri)); + DisplayResolveInfo displayResolveInfo = + DisplayResolveInfo.newDisplayResolveInfo(ii, ri, ii); mCallerTargets.add(displayResolveInfo); if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break; } @@ -238,6 +303,9 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override public void handlePackagesChanged() { + if (mPackageChangeCallback != null) { + mPackageChangeCallback.beforeHandlingPackagesChanged(); + } if (DEBUG) { Log.d(TAG, "clearing queryTargets on package change"); } @@ -247,7 +315,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } @Override - protected boolean rebuildList(boolean doPostProcessing) { + public boolean rebuildList(boolean doPostProcessing) { mAnimationTracker.reset(); mSortedList.clear(); boolean result = super.rebuildList(doPostProcessing); @@ -272,75 +340,77 @@ public class ChooserListAdapter extends ResolverListAdapter { public void onBindView(View view, TargetInfo info, int position) { final ViewHolder holder = (ViewHolder) view.getTag(); + holder.reset(); + // Always remove the spacing listener, attach as needed to direct share targets below. + holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); + if (info == null) { holder.icon.setImageDrawable(loadIconPlaceholder()); return; } - holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo()); - mAnimationTracker.animateLabel(holder.text, info); - if (holder.text2.getVisibility() == View.VISIBLE) { + final CharSequence displayLabel = Objects.requireNonNullElse(info.getDisplayLabel(), ""); + final CharSequence extendedInfo = Objects.requireNonNullElse(info.getExtendedInfo(), ""); + holder.bindLabel(displayLabel, extendedInfo); + if (!TextUtils.isEmpty(displayLabel)) { + mAnimationTracker.animateLabel(holder.text, info); + } + if (!TextUtils.isEmpty(extendedInfo) && holder.text2.getVisibility() == View.VISIBLE) { mAnimationTracker.animateLabel(holder.text2, info); } + holder.bindIcon(info); - if (info.getDisplayIconHolder().getDisplayIcon() != null) { + if (info.hasDisplayIcon()) { mAnimationTracker.animateIcon(holder.icon, info); - } else { - holder.icon.clearAnimation(); } if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); - CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; - CharSequence extendedInfo = info.getExtendedInfo(); - String contentDescription = String.join(" ", info.getDisplayLabel(), - extendedInfo != null ? extendedInfo : "", appName); + CharSequence appName = + Objects.requireNonNullElse(rInfo == null ? null : rInfo.getDisplayLabel(), ""); + String contentDescription = + String.join(" ", info.getDisplayLabel(), extendedInfo, appName); + if (info.isPinned()) { + contentDescription = String.join( + ". ", + contentDescription, + mContext.getResources().getString(R.string.pinned)); + } holder.updateContentDescription(contentDescription); if (!info.hasDisplayIcon()) { loadDirectShareIcon((SelectableTargetInfo) info); } } else if (info.isDisplayResolveInfo()) { + if (info.isPinned()) { + holder.updateContentDescription(String.join( + ". ", + info.getDisplayLabel(), + mContext.getResources().getString(R.string.pinned))); + } DisplayResolveInfo dri = (DisplayResolveInfo) info; if (!dri.hasDisplayIcon()) { loadIcon(dri); } + if (!dri.hasDisplayLabel()) { + loadLabel(dri); + } } - // If target is loading, show a special placeholder shape in the label, make unclickable if (info.isPlaceHolderTargetInfo()) { - final int maxWidth = mContext.getResources().getDimensionPixelSize( - R.dimen.chooser_direct_share_label_placeholder_max_width); - holder.text.setMaxWidth(maxWidth); - holder.text.setBackground(mContext.getResources().getDrawable( - R.drawable.chooser_direct_share_label_placeholder, mContext.getTheme())); - // Prevent rippling by removing background containing ripple - holder.itemView.setBackground(null); - } else { - holder.text.setMaxWidth(Integer.MAX_VALUE); - holder.text.setBackground(null); - holder.itemView.setBackground(holder.defaultItemViewBackground); + holder.bindPlaceholder(); } - // Always remove the spacing listener, attach as needed to direct share targets below. - holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); - if (info.isMultiDisplayResolveInfo()) { // If the target is grouped show an indicator - Drawable bkg = mContext.getDrawable(R.drawable.chooser_group_background); - holder.text.setPaddingRelative(0, 0, bkg.getIntrinsicWidth() /* end */, 0); - holder.text.setBackground(bkg); + holder.bindGroupIndicator( + mContext.getDrawable(R.drawable.chooser_group_background)); } else if (info.isPinned() && (getPositionTargetType(position) == TARGET_STANDARD || getPositionTargetType(position) == TARGET_SERVICE)) { // If the appShare or directShare target is pinned and in the suggested row show a // pinned indicator - Drawable bkg = mContext.getDrawable(R.drawable.chooser_pinned_background); - holder.text.setPaddingRelative(bkg.getIntrinsicWidth() /* start */, 0, 0, 0); - holder.text.setBackground(bkg); + holder.bindPinnedIndicator(mContext.getDrawable(R.drawable.chooser_pinned_background)); holder.text.addOnLayoutChangeListener(mPinTextSpacingListener); - } else { - holder.text.setBackground(null); - holder.text.setPaddingRelative(0, 0, 0, 0); } } @@ -360,9 +430,13 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - void updateAlphabeticalList() { - // TODO: this procedure seems like it should be relatively lightweight. Why does it need to - // run in an `AsyncTask`? + public void updateAlphabeticalList() { + final ChooserActivity.AzInfoComparator comparator = + new ChooserActivity.AzInfoComparator(mContext); + final List<DisplayResolveInfo> allTargets = new ArrayList<>(); + allTargets.addAll(getTargetsInCurrentDisplayList()); + allTargets.addAll(mCallerTargets); + new AsyncTask<Void, Void, List<DisplayResolveInfo>>() { @Override protected List<DisplayResolveInfo> doInBackground(Void... voids) { @@ -375,32 +449,39 @@ public class ChooserListAdapter extends ResolverListAdapter { } private List<DisplayResolveInfo> updateList() { - List<DisplayResolveInfo> allTargets = new ArrayList<>(); - allTargets.addAll(getTargetsInCurrentDisplayList()); - allTargets.addAll(mCallerTargets); + loadMissingLabels(allTargets); // Consolidate multiple targets from same app. return allTargets .stream() .collect(Collectors.groupingBy(target -> target.getResolvedComponentName().getPackageName() - + "#" + target.getDisplayLabel() - + '#' + target.getResolveInfo().userHandle.getIdentifier() + + "#" + target.getDisplayLabel() + + '#' + target.getResolveInfo().userHandle.getIdentifier() )) .values() .stream() .map(appTargets -> (appTargets.size() == 1) - ? appTargets.get(0) - : MultiDisplayResolveInfo.newMultiDisplayResolveInfo(appTargets)) - .sorted(new ChooserActivity.AzInfoComparator(mContext)) + ? appTargets.get(0) + : MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + appTargets)) + .sorted(comparator) .collect(Collectors.toList()); } + @Override protected void onPostExecute(List<DisplayResolveInfo> newList) { - mSortedList = newList; + mSortedList.clear(); + mSortedList.addAll(newList); notifyDataSetChanged(); } + + private void loadMissingLabels(List<DisplayResolveInfo> targets) { + for (DisplayResolveInfo target: targets) { + mTargetDataLoader.getOrLoadLabel(target); + } + } }.execute(); } @@ -438,8 +519,14 @@ public class ChooserListAdapter extends ResolverListAdapter { return count; } + private static boolean hasSendAction(Intent intent) { + String action = intent.getAction(); + return Intent.ACTION_SEND.equals(action) + || Intent.ACTION_SEND_MULTIPLE.equals(action); + } + public int getServiceTargetCount() { - if (mChooserRequest.isSendActionTarget() && !ActivityManager.isLowRamDeviceStatic()) { + if (hasSendAction(getTargetIntent()) && !ActivityManager.isLowRamDeviceStatic()) { return Math.min(mServiceTargets.size(), mMaxRankedTargets); } @@ -553,7 +640,7 @@ public class ChooserListAdapter extends ResolverListAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in callerTargets. for (TargetInfo existingInfo : mCallerTargets) { - if (mResolverListCommunicator.resolveInfoMatch( + if (ResolveInfoHelpers.resolveInfoMatch( dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } @@ -594,8 +681,8 @@ public class ChooserListAdapter extends ResolverListAdapter { directShareToShortcutInfos, directShareToAppTargets, mContext.createContextAsUser(getUserHandle(), 0), - mChooserRequest.getTargetIntent(), - mChooserRequest.getReferrerFillInIntent(), + getTargetIntent(), + mReferrerFillInIntent, mMaxRankedTargets, mServiceTargets); if (isUpdated) { @@ -644,29 +731,23 @@ public class ChooserListAdapter extends ResolverListAdapter { * in the head of input list and fill the tail with other elements in undetermined order. */ @Override - AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>> createSortingTask(boolean doPostProcessing) { - return new AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>>() { - @Override - protected List<ResolvedComponentInfo> doInBackground( - List<ResolvedComponentInfo>... params) { - Trace.beginSection("ChooserListAdapter#SortingTask"); - mResolverListController.topK(params[0], mMaxRankedTargets); - Trace.endSection(); - return params[0]; - } - @Override - protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) { - processSortedList(sortedComponents, doPostProcessing); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - notifyDataSetChanged(); - } - } - }; + @WorkerThread + protected void sortComponents(List<ResolvedComponentInfo> components) { + Trace.beginSection("ChooserListAdapter#SortingTask"); + mResolverListController.topK(components, mMaxRankedTargets); + Trace.endSection(); } + @Override + @MainThread + protected void onComponentsSorted( + @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { + processSortedList(sortedComponents, doPostProcessing); + if (doPostProcessing) { + mResolverListCommunicator.updateProfileViewButton(); + //TODO: this method is different from super's only in that `notifyDataSetChanged` is + // called conditionally here; is it really important? + notifyDataSetChanged(); + } + } } diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index c159243e..080f9d24 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -25,6 +25,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.PagerAdapter; +import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.measurements.Tracer; import com.android.internal.annotations.VisibleForTesting; @@ -38,21 +39,22 @@ import java.util.function.Supplier; * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. */ @VisibleForTesting -public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAdapter< +public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< RecyclerView, ChooserGridAdapter, ChooserListAdapter> { private static final int SINGLE_CELL_SPAN_SIZE = 1; private final ChooserProfileAdapterBinder mAdapterBinder; private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ChooserMultiProfilePagerAdapter( + public ChooserMultiProfilePagerAdapter( Context context, ChooserGridAdapter adapter, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - int maxTargetsPerRow) { + int maxTargetsPerRow, + FeatureFlags featureFlags) { this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), @@ -62,10 +64,11 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda /* defaultProfile= */ 0, workProfileUserHandle, cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context)); + new BottomPaddingOverrideSupplier(context), + featureFlags); } - ChooserMultiProfilePagerAdapter( + public ChooserMultiProfilePagerAdapter( Context context, ChooserGridAdapter personalAdapter, ChooserGridAdapter workAdapter, @@ -74,7 +77,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda @Profile int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - int maxTargetsPerRow) { + int maxTargetsPerRow, + FeatureFlags featureFlags) { this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), @@ -84,7 +88,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context)); + new BottomPaddingOverrideSupplier(context), + featureFlags); } private ChooserMultiProfilePagerAdapter( @@ -96,9 +101,9 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda @Profile int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, + FeatureFlags featureFlags) { super( - context, gridAdapter -> gridAdapter.getListAdapter(), adapterBinder, gridAdapters, @@ -107,7 +112,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - () -> makeProfileView(context), + () -> makeProfileView(context, featureFlags), bottomPaddingOverrideSupplier); mAdapterBinder = adapterBinder; mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; @@ -131,10 +136,12 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda } } - private static ViewGroup makeProfileView(Context context) { + private static ViewGroup makeProfileView( + Context context, FeatureFlags featureFlags) { LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = (ViewGroup) inflater.inflate( - R.layout.chooser_list_per_profile, null, false); + ViewGroup rootView = featureFlags.scrollablePreview() + ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) + : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); recyclerView.setAccessibilityDelegateCompat( new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); @@ -142,7 +149,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda } @Override - boolean rebuildActiveTab(boolean doPostProcessing) { + public boolean rebuildActiveTab(boolean doPostProcessing) { if (doPostProcessing) { Tracer.INSTANCE.beginAppTargetLoadingSection(getActiveListAdapter().getUserHandle()); } @@ -150,7 +157,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda } @Override - boolean rebuildInactiveTab(boolean doPostProcessing) { + public boolean rebuildInactiveTab(boolean doPostProcessing) { if (getItemCount() != 1 && doPostProcessing) { Tracer.INSTANCE.beginAppTargetLoadingSection(getInactiveListAdapter().getUserHandle()); } diff --git a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java index 250b6827..d6688d90 100644 --- a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java +++ b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java @@ -16,20 +16,20 @@ package com.android.intentresolver; -import android.annotation.NonNull; import android.graphics.Rect; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; +import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; -class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { +public class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { private final Rect mTempRect = new Rect(); private final int[] mConsumed = new int[2]; - ChooserRecyclerViewAccessibilityDelegate(RecyclerView recyclerView) { + public ChooserRecyclerViewAccessibilityDelegate(RecyclerView recyclerView) { super(recyclerView); } diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index 2ebe48a6..474b240f 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; -import android.annotation.UiThread; import android.app.Activity; import android.app.Application; import android.content.Intent; @@ -28,22 +26,30 @@ import android.os.Parcel; import android.os.ResultReceiver; import android.util.Log; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import com.android.intentresolver.chooser.TargetInfo; +import dagger.hilt.android.lifecycle.HiltViewModel; + import java.util.List; import java.util.function.Consumer; +import javax.inject.Inject; + + /** * Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement * activity" that will be invoked when a target is selected, allowing the calling app to add - * additional extras and other refinements (subject to {@link Intent#filterEquals()}), e.g., to + * additional extras and other refinements (subject to {@link Intent#filterEquals}), e.g., to * convert the format of the payload, or lazy-download some data that was deferred in the original * call). */ +@HiltViewModel @UiThread public final class ChooserRefinementManager extends ViewModel { private static final String TAG = "ChooserRefinement"; @@ -88,6 +94,9 @@ public final class ChooserRefinementManager extends ViewModel { private MutableLiveData<RefinementCompletion> mRefinementCompletion = new MutableLiveData<>(); + @Inject + public ChooserRefinementManager() {} + public LiveData<RefinementCompletion> getRefinementCompletion() { return mRefinementCompletion; } diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 5157986b..7ad809e9 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.content.ComponentName; import android.content.Intent; import android.content.IntentFilter; @@ -32,7 +30,9 @@ import android.text.TextUtils; import android.util.Log; import android.util.Pair; -import com.android.intentresolver.flags.FeatureFlagRepository; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.intentresolver.util.UriFilters; import com.google.common.collect.ImmutableList; @@ -104,8 +104,7 @@ public class ChooserRequestParameters { public ChooserRequestParameters( final Intent clientIntent, String referrerPackageName, - final Uri referrer, - FeatureFlagRepository featureFlags) { + final Uri referrer) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); @@ -212,7 +211,7 @@ public class ChooserRequestParameters { /** * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link mAdditionalTargets} directly is simpler and safer. + * refactored, returning {@link #mAdditionalTargets} directly is simpler and safer. */ @Nullable public Intent[] getAdditionalTargets() { @@ -226,7 +225,7 @@ public class ChooserRequestParameters { /** * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link mInitialIntents} directly is simpler and safer. + * refactored, returning {@link #mInitialIntents} directly is simpler and safer. */ @Nullable public Intent[] getInitialIntents() { @@ -288,7 +287,7 @@ public class ChooserRequestParameters { * requested target <em>wasn't</em> a send action; otherwise it is null. The second value is * the resource ID of a default title string; this is nonzero only if the first value is null. * - * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate()}, or + * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate}, or * create a real type (not {@link Pair}) to express the semantics described in this comment. */ private static Pair<CharSequence, Integer> makeTitleSpec( @@ -371,7 +370,7 @@ public class ChooserRequestParameters { * the required type. If false, throw an {@link IllegalArgumentException} if the extra is * non-null but can't be assigned to variables of type {@code T}. * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't - * present in the intent (or if it had the wrong type, but {@link warnOnTypeError} is true). + * present in the intent (or if it had the wrong type, but <em>warnOnTypeError</em> is true). * If false, return null in these cases, and only return an empty stream if the intent * explicitly provided an empty array for the specified extra. */ diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java index 2cfceeae..f0fcd149 100644 --- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java @@ -22,6 +22,7 @@ import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.os.UserHandle; +import androidx.annotation.NonNull; import androidx.fragment.app.FragmentManager; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -66,6 +67,7 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF dismiss(); } + @NonNull @Override protected CharSequence getItemLabel(DisplayResolveInfo dri) { final PackageManager pm = getContext().getPackageManager(); diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index 4bfb21aa..b6b7de96 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -21,8 +21,6 @@ import static android.content.Context.ACTIVITY_SERVICE; import static java.util.stream.Collectors.toList; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.ActivityManager; import android.app.Dialog; import android.content.ComponentName; @@ -46,6 +44,8 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.RecyclerView; diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java deleted file mode 100644 index a1c53402..00000000 --- a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java +++ /dev/null @@ -1,235 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.annotation.Nullable; -import android.content.Context; -import android.os.UserHandle; -import android.util.Log; -import android.view.View; -import android.view.ViewGroup; - -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Implementation of {@link AbstractMultiProfilePagerAdapter} that consolidates the variation in - * existing implementations; most overrides were only to vary type signatures (which are better - * represented via generic types), and a few minor behavioral customizations are now implemented - * through small injectable delegate classes. - * TODO: now that the existing implementations are shown to be expressible in terms of this new - * generic type, merge up into the base class and simplify the public APIs. - * TODO: attempt to further restrict visibility in the methods we expose. - * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" - * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident - * waiting to happen since clients seem to make assumptions about which adapter will be "active" in - * a particular context, and more explicit APIs would make sure those were valid. - * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) - * - * @param <PageViewT> the type of the widget that represents the contents of a page in this adapter - * @param <SinglePageAdapterT> the type of a "root" adapter class to be instantiated and included in - * the per-profile records. - * @param <ListAdapterT> the concrete type of a {@link ResolverListAdapter} implementation to - * control the contents of a given per-profile list. This is provided for convenience, since it must - * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. - * - * TODO: this class doesn't make any explicit usage of the {@link ResolverListAdapter} API, so the - * type constraint can probably be dropped once the API is merged upwards and cleaned. - */ -class GenericMultiProfilePagerAdapter< - PageViewT extends ViewGroup, - SinglePageAdapterT, - ListAdapterT extends ResolverListAdapter> extends AbstractMultiProfilePagerAdapter { - - /** Delegate to set up a given adapter and page view to be used together. */ - public interface AdapterBinder<PageViewT, SinglePageAdapterT> { - /** - * The given {@code view} will be associated with the given {@code adapter}. Do any work - * necessary to configure them compatibly, introduce them to each other, etc. - */ - void bind(PageViewT view, SinglePageAdapterT adapter); - } - - private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; - private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder; - private final Supplier<ViewGroup> mPageViewInflater; - private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier; - - private final ImmutableList<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems; - - GenericMultiProfilePagerAdapter( - Context context, - Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor, - AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder, - ImmutableList<SinglePageAdapterT> adapters, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - Supplier<ViewGroup> pageViewInflater, - Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { - super( - context, - /* currentPage= */ defaultProfile, - emptyStateProvider, - workProfileQuietModeChecker, - workProfileUserHandle, - cloneProfileUserHandle); - - mListAdapterExtractor = listAdapterExtractor; - mAdapterBinder = adapterBinder; - mPageViewInflater = pageViewInflater; - mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; - - ImmutableList.Builder<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> items = - new ImmutableList.Builder<>(); - for (SinglePageAdapterT adapter : adapters) { - items.add(createProfileDescriptor(adapter)); - } - mItems = items.build(); - } - - private GenericProfileDescriptor<PageViewT, SinglePageAdapterT> - createProfileDescriptor(SinglePageAdapterT adapter) { - return new GenericProfileDescriptor<>(mPageViewInflater.get(), adapter); - } - - @Override - protected GenericProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) { - return mItems.get(pageIndex); - } - - @Override - public int getItemCount() { - return mItems.size(); - } - - public PageViewT getListViewForIndex(int index) { - return getItem(index).mView; - } - - @Override - @VisibleForTesting - public SinglePageAdapterT getAdapterForIndex(int index) { - return getItem(index).mAdapter; - } - - @Override - protected void setupListAdapter(int pageIndex) { - mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); - } - - @Override - public ViewGroup instantiateItem(ViewGroup container, int position) { - setupListAdapter(position); - return super.instantiateItem(container, position); - } - - @Override - @Nullable - protected ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { - if (getPersonalListAdapter().getUserHandle().equals(userHandle) - || userHandle.equals(getCloneUserHandle())) { - return getPersonalListAdapter(); - } else if (getWorkListAdapter() != null - && getWorkListAdapter().getUserHandle().equals(userHandle)) { - return getWorkListAdapter(); - } - return null; - } - - @Override - @VisibleForTesting - public ListAdapterT getActiveListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); - } - - @Override - @VisibleForTesting - public ListAdapterT getInactiveListAdapter() { - if (getCount() < 2) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); - } - - @Override - public ListAdapterT getPersonalListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); - } - - @Override - public ListAdapterT getWorkListAdapter() { - if (!hasAdapterForIndex(PROFILE_WORK)) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); - } - - @Override - protected SinglePageAdapterT getCurrentRootAdapter() { - return getAdapterForIndex(getCurrentPage()); - } - - @Override - protected PageViewT getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - @Override - protected PageViewT getInactiveAdapterView() { - if (getCount() < 2) { - return null; - } - return getListViewForIndex(1 - getCurrentPage()); - } - - @Override - protected void setupContainerPadding(View container) { - Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); - bottomPaddingOverride.ifPresent(paddingBottom -> - container.setPadding( - container.getPaddingLeft(), - container.getPaddingTop(), - container.getPaddingRight(), - paddingBottom)); - } - - private boolean hasAdapterForIndex(int pageIndex) { - return (pageIndex < getCount()); - } - - // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" - // should be the owner of all per-profile data (especially now that the API is generic)? - private static class GenericProfileDescriptor<PageViewT, SinglePageAdapterT> extends - ProfileDescriptor { - private final SinglePageAdapterT mAdapter; - private final PageViewT mView; - - GenericProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) { - super(rootView); - mAdapter = adapter; - mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); - } - } -} diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index 5e8945f1..15996d00 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -23,7 +23,6 @@ import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY; import static com.android.intentresolver.ResolverActivity.EXTRA_CALLING_USER; import static com.android.intentresolver.ResolverActivity.EXTRA_SELECTED_PROFILE; -import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityThread; import android.app.AppGlobals; @@ -45,6 +44,8 @@ import android.provider.Settings; import android.util.Slog; import android.widget.Toast; +import androidx.annotation.Nullable; + import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -309,7 +310,7 @@ public class IntentForwarderActivity extends Activity { * Check whether the intent can be forwarded to target user. Return the intent used for * forwarding if it can be forwarded, {@code null} otherwise. */ - static Intent canForward(Intent incomingIntent, int sourceUserId, int targetUserId, + public static Intent canForward(Intent incomingIntent, int sourceUserId, int targetUserId, IPackageManager packageManager, ContentResolver contentResolver) { Intent forwardIntent = new Intent(incomingIntent); forwardIntent.addFlags( diff --git a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt b/java/src/com/android/intentresolver/MainApplication.kt index 5b5d769c..0a826629 100644 --- a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt +++ b/java/src/com/android/intentresolver/MainApplication.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 The Android Open Source Project + * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,12 +14,9 @@ * limitations under the License. */ -package com.android.intentresolver.flags +package com.android.intentresolver -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag +import android.app.Application +import dagger.hilt.android.HiltAndroidApp -interface FeatureFlagRepository { - fun isEnabled(flag: UnreleasedFlag): Boolean - fun isEnabled(flag: ReleasedFlag): Boolean -} +@HiltAndroidApp(Application::class) open class MainApplication : Hilt_MainApplication() diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java index 4b06db3b..42a29e55 100644 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java @@ -15,15 +15,6 @@ */ package com.android.intentresolver; -import android.annotation.IntDef; -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.annotation.UserIdInt; -import android.app.AppGlobals; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.pm.IPackageManager; import android.os.Trace; import android.os.UserHandle; import android.view.View; @@ -31,62 +22,124 @@ import android.view.ViewGroup; import android.widget.Button; import android.widget.TextView; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.EmptyStateUiHelper; import com.android.internal.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; + import java.util.HashSet; -import java.util.List; -import java.util.Objects; +import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.function.Supplier; /** - * Skeletal {@link PagerAdapter} implementation of a work or personal profile page for - * intent resolution (including share sheet). + * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). + * + * TODO: attempt to further restrict visibility/improve encapsulation in the methods we expose. + * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" + * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident + * waiting to happen since clients seem to make assumptions about which adapter will be "active" in + * a particular context, and more explicit APIs would make sure those were valid. + * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) + * + * @param <PageViewT> the type of the widget that represents the contents of a page in this adapter + * @param <SinglePageAdapterT> the type of a "root" adapter class to be instantiated and included in + * the per-profile records. + * @param <ListAdapterT> the concrete type of a {@link ResolverListAdapter} implementation to + * control the contents of a given per-profile list. This is provided for convenience, since it must + * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. + * + * TODO: this is part of an in-progress refactor to merge with `GenericMultiProfilePagerAdapter`. + * As originally noted there, we've reduced explicit references to the `ResolverListAdapter` base + * type and may be able to drop the type constraint. */ -public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { +public class MultiProfilePagerAdapter< + PageViewT extends ViewGroup, + SinglePageAdapterT, + ListAdapterT extends ResolverListAdapter> extends PagerAdapter { + + /** + * Delegate to set up a given adapter and page view to be used together. + * @param <PageViewT> (as in {@link MultiProfilePagerAdapter}). + * @param <SinglePageAdapterT> (as in {@link MultiProfilePagerAdapter}). + */ + public interface AdapterBinder<PageViewT, SinglePageAdapterT> { + /** + * The given {@code view} will be associated with the given {@code adapter}. Do any work + * necessary to configure them compatibly, introduce them to each other, etc. + */ + void bind(PageViewT view, SinglePageAdapterT adapter); + } - private static final String TAG = "AbstractMultiProfilePagerAdapter"; - 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 Context mContext; - private int mCurrentPage; - private OnProfileSelectedListener mOnProfileSelectedListener; + private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; + private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder; + private final Supplier<ViewGroup> mPageViewInflater; + private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier; + + private final ImmutableList<ProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems; - private Set<Integer> mLoadedPages; private final EmptyStateProvider mEmptyStateProvider; private final UserHandle mWorkProfileUserHandle; private final UserHandle mCloneProfileUserHandle; private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet. - AbstractMultiProfilePagerAdapter( - Context context, - int currentPage, + private Set<Integer> mLoadedPages; + private int mCurrentPage; + private OnProfileSelectedListener mOnProfileSelectedListener; + + protected MultiProfilePagerAdapter( + Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor, + AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder, + ImmutableList<SinglePageAdapterT> adapters, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, + @Profile int defaultProfile, UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - mContext = Objects.requireNonNull(context); - mCurrentPage = currentPage; + UserHandle cloneProfileUserHandle, + Supplier<ViewGroup> pageViewInflater, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + mCurrentPage = defaultProfile; mLoadedPages = new HashSet<>(); mWorkProfileUserHandle = workProfileUserHandle; mCloneProfileUserHandle = cloneProfileUserHandle; mEmptyStateProvider = emptyStateProvider; mWorkProfileQuietModeChecker = workProfileQuietModeChecker; + + mListAdapterExtractor = listAdapterExtractor; + mAdapterBinder = adapterBinder; + mPageViewInflater = pageViewInflater; + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; + + ImmutableList.Builder<ProfileDescriptor<PageViewT, SinglePageAdapterT>> items = + new ImmutableList.Builder<>(); + for (SinglePageAdapterT adapter : adapters) { + items.add(createProfileDescriptor(adapter)); + } + mItems = items.build(); } - void setOnProfileSelectedListener(OnProfileSelectedListener listener) { - mOnProfileSelectedListener = listener; + private ProfileDescriptor<PageViewT, SinglePageAdapterT> createProfileDescriptor( + SinglePageAdapterT adapter) { + return new ProfileDescriptor<>(mPageViewInflater.get(), adapter); } - Context getContext() { - return mContext; + public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { + mOnProfileSelectedListener = listener; } /** @@ -94,7 +147,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed * page and rebuilds the list. */ - void setupViewPager(ViewPager viewPager) { + public void setupViewPager(ViewPager viewPager) { viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { @Override public void onPageSelected(int position) { @@ -120,22 +173,24 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { mLoadedPages.add(mCurrentPage); } - void clearInactiveProfileCache() { + public void clearInactiveProfileCache() { if (mLoadedPages.size() == 1) { return; } mLoadedPages.remove(1 - mCurrentPage); } + @NonNull @Override - public ViewGroup instantiateItem(ViewGroup container, int position) { - final ProfileDescriptor profileDescriptor = getItem(position); - container.addView(profileDescriptor.rootView); - return profileDescriptor.rootView; + public final ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + final ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem(position); + container.addView(descriptor.mRootView); + return descriptor.mRootView; } @Override - public void destroyItem(ViewGroup container, int position, Object view) { + public void destroyItem(ViewGroup container, int position, @NonNull Object view) { container.removeView((View) view); } @@ -144,7 +199,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { return getItemCount(); } - protected int getCurrentPage() { + public int getCurrentPage() { return mCurrentPage; } @@ -154,7 +209,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } @Override - public boolean isViewFromObject(View view, Object object) { + public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { return view == object; } @@ -177,9 +232,11 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li> * </ul> */ - abstract ProfileDescriptor getItem(int pageIndex); + private ProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) { + return mItems.get(pageIndex); + } - protected ViewGroup getEmptyStateView(int pageIndex) { + public ViewGroup getEmptyStateView(int pageIndex) { return getItem(pageIndex).getEmptyStateView(); } @@ -188,13 +245,13 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * <p>For a normal consumer device with only one user returns <code>1</code>. * <p>For a device with a work profile returns <code>2</code>. */ - abstract int getItemCount(); + public final int getItemCount() { + return mItems.size(); + } - /** - * Performs view-related initialization procedures for the adapter specified - * by <code>pageIndex</code>. - */ - abstract void setupListAdapter(int pageIndex); + public final PageViewT getListViewForIndex(int index) { + return getItem(index).mView; + } /** * Returns the adapter of the list view for the relevant page specified by @@ -203,54 +260,99 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * depending on the adapter type. */ @VisibleForTesting - public abstract Object getAdapterForIndex(int pageIndex); + public final SinglePageAdapterT getAdapterForIndex(int index) { + return getItem(index).mAdapter; + } /** - * Returns the {@link ResolverListAdapter} instance of the profile that represents + * Performs view-related initialization procedures for the adapter specified + * by <code>pageIndex</code>. + */ + public final void setupListAdapter(int pageIndex) { + mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that represents * <code>userHandle</code>. If there is no such adapter for the specified * <code>userHandle</code>, returns {@code null}. * <p>For example, if there is a work profile on the device with user id 10, calling this method - * with <code>UserHandle.of(10)</code> returns the work profile {@link ResolverListAdapter}. + * with <code>UserHandle.of(10)</code> returns the work profile {@link ListAdapterT}. */ @Nullable - abstract ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle); + public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + if (getPersonalListAdapter().getUserHandle().equals(userHandle) + || userHandle.equals(getCloneUserHandle())) { + return getPersonalListAdapter(); + } else if ((getWorkListAdapter() != null) + && getWorkListAdapter().getUserHandle().equals(userHandle)) { + return getWorkListAdapter(); + } + return null; + } /** - * Returns the {@link ResolverListAdapter} instance of the profile that is currently visible + * Returns the {@link ListAdapterT} instance of the profile that is currently visible * to the user. * <p>For example, if the user is viewing the work tab in the share sheet, this method returns - * the work profile {@link ResolverListAdapter}. + * the work profile {@link ListAdapterT}. * @see #getInactiveListAdapter() */ @VisibleForTesting - public abstract ResolverListAdapter getActiveListAdapter(); + public final ListAdapterT getActiveListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); + } /** - * If this is a device with a work profile, returns the {@link ResolverListAdapter} instance + * If this is a device with a work profile, returns the {@link ListAdapterT} instance * of the profile that is <b><i>not</i></b> currently visible to the user. Otherwise returns * {@code null}. * <p>For example, if the user is viewing the work tab in the share sheet, this method returns - * the personal profile {@link ResolverListAdapter}. + * the personal profile {@link ListAdapterT}. * @see #getActiveListAdapter() */ @VisibleForTesting - public abstract @Nullable ResolverListAdapter getInactiveListAdapter(); + @Nullable + public final ListAdapterT getInactiveListAdapter() { + if (getCount() < 2) { + return null; + } + return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); + } - public abstract ResolverListAdapter getPersonalListAdapter(); + public final ListAdapterT getPersonalListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); + } - public abstract @Nullable ResolverListAdapter getWorkListAdapter(); + @Nullable + public final ListAdapterT getWorkListAdapter() { + if (!hasAdapterForIndex(PROFILE_WORK)) { + return null; + } + return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); + } - abstract Object getCurrentRootAdapter(); + public final SinglePageAdapterT getCurrentRootAdapter() { + return getAdapterForIndex(getCurrentPage()); + } - abstract ViewGroup getActiveAdapterView(); + public final PageViewT getActiveAdapterView() { + return getListViewForIndex(getCurrentPage()); + } - abstract @Nullable ViewGroup getInactiveAdapterView(); + @Nullable + public final PageViewT getInactiveAdapterView() { + if (getCount() < 2) { + return null; + } + return getListViewForIndex(1 - getCurrentPage()); + } /** * Rebuilds the tab that is currently visible to the user. * <p>Returns {@code true} if rebuild has completed. */ - boolean rebuildActiveTab(boolean doPostProcessing) { + public boolean rebuildActiveTab(boolean doPostProcessing) { Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); Trace.endSection(); @@ -261,7 +363,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * Rebuilds the tab that is not currently visible to the user, if such one exists. * <p>Returns {@code true} if rebuild has completed. */ - boolean rebuildInactiveTab(boolean doPostProcessing) { + public boolean rebuildInactiveTab(boolean doPostProcessing) { Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); if (getItemCount() == 1) { Trace.endSection(); @@ -280,7 +382,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } } - private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) { + private boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { if (shouldSkipRebuild(activeListAdapter)) { activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); return false; @@ -288,16 +390,20 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { return activeListAdapter.rebuildList(doPostProcessing); } - private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) { + private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) { EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); return emptyState != null && emptyState.shouldSkipDataRebuild(); } + private boolean hasAdapterForIndex(int pageIndex) { + return (pageIndex < getCount()); + } + /** * The empty state screens are shown according to their priority: * <ol> * <li>(highest priority) cross-profile disabled by policy (handled in - * {@link #rebuildTab(ResolverListAdapter, boolean)})</li> + * {@link #rebuildTab(ListAdapterT, boolean)})</li> * <li>no apps available</li> * <li>(least priority) work is off</li> * </ol> @@ -306,7 +412,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * the work profile on if there will not be any apps resolved * anyway. */ - void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) { + public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); if (emptyState == null) { @@ -319,9 +425,9 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { if (emptyState.getButtonClickListener() != null) { clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { - ProfileDescriptor descriptor = getItem( + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( userHandleToPageIndex(listAdapter.getUserHandle())); - AbstractMultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView()); + descriptor.mEmptyStateUi.showSpinner(); }); } @@ -340,45 +446,24 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } } - /** - * Utility class to check if there are cross profile intents, it is in a separate class so - * it could be mocked in tests - */ - public static class CrossProfileIntentsChecker { - - private final ContentResolver mContentResolver; - - public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { - mContentResolver = contentResolver; - } - - /** - * Returns {@code true} if at least one of the provided {@code intents} can be forwarded - * from {@code source} (user id) to {@code target} (user id). - */ - public boolean hasCrossProfileIntents(List<Intent> intents, @UserIdInt int source, - @UserIdInt int target) { - IPackageManager packageManager = AppGlobals.getPackageManager(); - - return intents.stream().anyMatch(intent -> - null != IntentForwarderActivity.canForward(intent, source, target, - packageManager, mContentResolver)); - } - } - - protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState, + protected void showEmptyState( + ListAdapterT activeListAdapter, + EmptyState emptyState, View.OnClickListener buttonOnClick) { - ProfileDescriptor descriptor = getItem( + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); + descriptor.mRootView.findViewById( + com.android.internal.R.id.resolver_list).setVisibility(View.GONE); + descriptor.mEmptyStateUi.resetViewVisibilities(); + ViewGroup emptyStateView = descriptor.getEmptyStateView(); - resetViewVisibilitiesForEmptyState(emptyStateView); - emptyStateView.setVisibility(View.VISIBLE); - View container = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_container); + View container = emptyStateView.findViewById( + com.android.internal.R.id.resolver_empty_state_container); setupContainerPadding(container); - TextView titleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title); + TextView titleView = emptyStateView.findViewById( + com.android.internal.R.id.resolver_empty_state_title); String title = emptyState.getTitle(); if (title != null) { titleView.setVisibility(View.VISIBLE); @@ -387,7 +472,8 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { titleView.setVisibility(View.GONE); } - TextView subtitleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle); + TextView subtitleView = emptyStateView.findViewById( + com.android.internal.R.id.resolver_empty_state_subtitle); String subtitle = emptyState.getSubtitle(); if (subtitle != null) { subtitleView.setVisibility(View.VISIBLE); @@ -399,7 +485,8 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty); defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); - Button button = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button); + Button button = emptyStateView.findViewById( + com.android.internal.R.id.resolver_empty_state_button); button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); button.setOnClickListener(buttonOnClick); @@ -410,44 +497,50 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { * Sets up the padding of the view containing the empty state screens. * <p>This method is meant to be overridden so that subclasses can customize the padding. */ - protected void setupContainerPadding(View container) {} - - private void showSpinner(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); - } - - private void resetViewVisibilitiesForEmptyState(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); - } - - protected void showListView(ResolverListAdapter activeListAdapter) { - ProfileDescriptor descriptor = getItem( + public void setupContainerPadding(View container) { + Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); + bottomPaddingOverride.ifPresent(paddingBottom -> + container.setPadding( + container.getPaddingLeft(), + container.getPaddingTop(), + container.getPaddingRight(), + paddingBottom)); + } + + public void showListView(ListAdapterT activeListAdapter) { + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE); - View emptyStateView = descriptor.rootView.findViewById(com.android.internal.R.id.resolver_empty_state); - emptyStateView.setVisibility(View.GONE); + descriptor.mRootView.findViewById( + com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE); + descriptor.mEmptyStateUi.hide(); } - boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) { + public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { int count = listAdapter.getUnfilteredCount(); return (count == 0 && listAdapter.getPlaceholderCount() == 0) || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) && mWorkProfileQuietModeChecker.get()); } - protected static class ProfileDescriptor { - final ViewGroup rootView; + // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" + // should be the owner of all per-profile data (especially now that the API is generic)? + private static class ProfileDescriptor<PageViewT, SinglePageAdapterT> { + final ViewGroup mRootView; + final EmptyStateUiHelper mEmptyStateUi; + + // TODO: post-refactoring, we may not need to retain these ivars directly (since they may + // be encapsulated within the `EmptyStateUiHelper`?). private final ViewGroup mEmptyStateView; - ProfileDescriptor(ViewGroup rootView) { - this.rootView = rootView; + + private final SinglePageAdapterT mAdapter; + private final PageViewT mView; + + ProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) { + mRootView = rootView; + mAdapter = adapter; mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); + mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); + mEmptyStateUi = new EmptyStateUiHelper(rootView); } protected ViewGroup getEmptyStateView() { @@ -455,6 +548,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } } + /** Listener interface for changes between the per-profile UI tabs. */ public interface OnProfileSelectedListener { /** * Callback for when the user changes the active tab from personal to work or vice versa. @@ -478,102 +572,9 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { } /** - * Returns an empty state to show for the current profile page (tab) if necessary. - * This could be used e.g. to show a blocker on a tab if device management policy doesn't - * allow to use it or there are no apps available. - */ - public interface EmptyStateProvider { - /** - * When a non-null empty state is returned the corresponding profile page will show - * this empty state - * @param resolverListAdapter the current adapter - */ - @Nullable - default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - return null; - } - } - - /** - * Empty state provider that combines multiple providers. Providers earlier in the list have - * priority, that is if there is a provider that returns non-null empty state then all further - * providers will be ignored. - */ - public static class CompositeEmptyStateProvider implements EmptyStateProvider { - - private final EmptyStateProvider[] mProviders; - - public CompositeEmptyStateProvider(EmptyStateProvider... providers) { - mProviders = providers; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - for (EmptyStateProvider provider : mProviders) { - EmptyState emptyState = provider.getEmptyState(resolverListAdapter); - if (emptyState != null) { - return emptyState; - } - } - return null; - } - } - - /** - * Describes how the blocked empty state should look like for a profile tab - */ - public interface EmptyState { - /** - * Title that will be shown on the empty state - */ - @Nullable - default String getTitle() { return null; } - - /** - * Subtitle that will be shown underneath the title on the empty state - */ - @Nullable - default String getSubtitle() { return null; } - - /** - * If non-null then a button will be shown and this listener will be called - * when the button is clicked - */ - @Nullable - default ClickListener getButtonClickListener() { return null; } - - /** - * If true then default text ('No apps can perform this action') and style for the empty - * state will be applied, title and subtitle will be ignored. - */ - default boolean useDefaultEmptyView() { return false; } - - /** - * Returns true if for this empty state we should skip rebuilding of the apps list - * for this tab. - */ - default boolean shouldSkipDataRebuild() { return false; } - - /** - * Called when empty state is shown, could be used e.g. to track analytics events - */ - default void onEmptyStateShown() {} - - interface ClickListener { - void onClick(TabControl currentTab); - } - - interface TabControl { - void showSpinner(); - } - } - - - /** * Listener for when the user switches on the work profile from the work tab. */ - interface OnSwitchOnWorkSelectedListener { + public interface OnSwitchOnWorkSelectedListener { /** * Callback for when the user switches on the work profile from the work tab. */ diff --git a/java/src/com/android/intentresolver/ResolvedComponentInfo.java b/java/src/com/android/intentresolver/ResolvedComponentInfo.java index ecb72cbf..aaa97c42 100644 --- a/java/src/com/android/intentresolver/ResolvedComponentInfo.java +++ b/java/src/com/android/intentresolver/ResolvedComponentInfo.java @@ -20,6 +20,8 @@ import android.content.ComponentName; import android.content.Intent; import android.content.pm.ResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; + import java.util.ArrayList; import java.util.List; @@ -86,7 +88,7 @@ public final class ResolvedComponentInfo { } /** - * @return whether this component was pinned by a call to {@link #setPinned()}. + * @return whether this component was pinned by a call to {@link #setPinned}. * TODO: consolidate sources of pinning data and/or document how this differs from other places * we make a "pinning" determination. */ diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 35c7e897..0331c33e 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -36,9 +36,6 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE 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; @@ -96,18 +93,26 @@ import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.annotation.UiThread; import androidx.fragment.app.FragmentActivity; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CompositeEmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; -import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.MultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.MultiProfilePagerAdapter.Profile; 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; @@ -199,8 +204,10 @@ public class ResolverActivity extends FragmentActivity implements private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; + private TargetDataLoader mTargetDataLoader; + @VisibleForTesting - protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter; + protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; protected WorkProfileAvailabilityManager mWorkProfileAvailability; @@ -227,8 +234,8 @@ public class ResolverActivity extends FragmentActivity implements static final String EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; - protected static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL; - protected static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK; + protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; + protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; private UserHandle mHeaderCreatorUser; @@ -239,11 +246,20 @@ public class ResolverActivity extends FragmentActivity implements // 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 = AnnotatedUserHandles.forShareActivity(this); + 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; @@ -418,6 +434,7 @@ public class ResolverActivity extends FragmentActivity implements 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 @@ -438,11 +455,12 @@ public class ResolverActivity extends FragmentActivity implements mPersonalPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getPersonalListAdapter()); mPersonalPackageMonitor.register( - this, getMainLooper(), getPersonalProfileUserHandle(), false); + this, getMainLooper(), getAnnotatedUserHandles().personalProfileUserHandle, false); if (shouldShowTabs()) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); - mWorkPackageMonitor.register(this, getMainLooper(), getWorkProfileUserHandle(), false); + mWorkPackageMonitor.register( + this, getMainLooper(), getAnnotatedUserHandles().workProfileUserHandle, false); } mRegistered = true; @@ -484,12 +502,12 @@ public class ResolverActivity extends FragmentActivity implements + (categories != null ? Arrays.toString(categories.toArray()) : "")); } - protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( + protected MultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List<ResolveInfo> resolutionList, boolean filterLastUsed, TargetDataLoader targetDataLoader) { - AbstractMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; + MultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; if (shouldShowTabs()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( @@ -509,9 +527,9 @@ public class ResolverActivity extends FragmentActivity implements return new EmptyStateProvider() {}; } - final AbstractMultiProfilePagerAdapter.EmptyState - noWorkToPersonalEmptyState = - new DevicePolicyBlockerEmptyState(/* context= */ this, + 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, @@ -521,8 +539,9 @@ public class ResolverActivity extends FragmentActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); - final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState = - new DevicePolicyBlockerEmptyState(/* context= */ this, + 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, @@ -532,9 +551,12 @@ public class ResolverActivity extends FragmentActivity implements /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); - return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), - noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch()); + return new NoCrossProfileEmptyStateProvider( + getAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } protected int appliedThemeResId() { @@ -591,7 +613,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override - public void onConfigurationChanged(Configuration newConfig) { + public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() @@ -1014,7 +1036,7 @@ public class ResolverActivity extends FragmentActivity implements @Override // ResolverListCommunicator public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) + 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 @@ -1052,16 +1074,15 @@ public class ResolverActivity extends FragmentActivity implements } protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - final UserHandle workUser = getWorkProfileUserHandle(); - return new WorkProfileAvailabilityManager( getSystemService(UserManager.class), - workUser, + getAnnotatedUserHandles().workProfileUserHandle, this::onWorkProfileStatusUpdated); } protected void onWorkProfileStatusUpdated() { - if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getWorkProfileUserHandle())) { + if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( + getAnnotatedUserHandles().workProfileUserHandle)) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); @@ -1079,8 +1100,8 @@ public class ResolverActivity extends FragmentActivity implements UserHandle userHandle, TargetDataLoader targetDataLoader) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getPersonalProfileUserHandle()) - ? getCloneProfileUserHandle() : userHandle; + && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) + ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; return new ResolverListAdapter( context, payloadIntents, @@ -1136,9 +1157,9 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( this, workProfileUserHandle, - getPersonalProfileUserHandle(), + getAnnotatedUserHandles().personalProfileUserHandle, getMetricsCategory(), - getTabOwnerUserHandleForLaunch() + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch ); // Return composite provider, the order matters (the higher, the more priority) @@ -1188,7 +1209,7 @@ public class ResolverActivity extends FragmentActivity implements initialIntents, resolutionList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle(), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); return new ResolverMultiProfilePagerAdapter( /* context */ this, @@ -1196,13 +1217,13 @@ public class ResolverActivity extends FragmentActivity implements createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, - getCloneProfileUserHandle()); + getAnnotatedUserHandles().cloneProfileUserHandle); } private UserHandle getIntentUser() { return getIntent().hasExtra(EXTRA_CALLING_USER) ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getTabOwnerUserHandleForLaunch(); + : getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; } private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( @@ -1215,10 +1236,10 @@ public class ResolverActivity extends FragmentActivity implements // this happens, we check for it here and set the current profile's tab. int selectedProfile = getCurrentProfile(); UserHandle intentUser = getIntentUser(); - if (!getTabOwnerUserHandleForLaunch().equals(intentUser)) { - if (getPersonalProfileUserHandle().equals(intentUser)) { + if (!getAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { + if (getAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; - } else if (getWorkProfileUserHandle().equals(intentUser)) { + } else if (getAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { selectedProfile = PROFILE_WORK; } } else { @@ -1236,10 +1257,10 @@ public class ResolverActivity extends FragmentActivity implements selectedProfile == PROFILE_PERSONAL ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() - == getPersonalProfileUserHandle().getIdentifier()), - /* userHandle */ getPersonalProfileUserHandle(), + == getAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), + /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, targetDataLoader); - UserHandle workProfileUserHandle = getWorkProfileUserHandle(); + UserHandle workProfileUserHandle = getAnnotatedUserHandles().workProfileUserHandle; ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, /* payloadIntents */ mIntents, @@ -1253,11 +1274,11 @@ public class ResolverActivity extends FragmentActivity implements /* context */ this, personalAdapter, workAdapter, - createEmptyStateProvider(getWorkProfileUserHandle()), + createEmptyStateProvider(workProfileUserHandle), () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, - getWorkProfileUserHandle(), - getCloneProfileUserHandle()); + workProfileUserHandle, + getAnnotatedUserHandles().cloneProfileUserHandle); } /** @@ -1280,55 +1301,29 @@ public class ResolverActivity extends FragmentActivity implements } protected final @Profile int getCurrentProfile() { - return (getTabOwnerUserHandleForLaunch().equals(getPersonalProfileUserHandle()) - ? PROFILE_PERSONAL : PROFILE_WORK); + UserHandle launchUser = getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + UserHandle personalUser = getAnnotatedUserHandles().personalProfileUserHandle; + return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; } protected final AnnotatedUserHandles getAnnotatedUserHandles() { return mLazyAnnotatedUserHandles.get(); } - protected final UserHandle getPersonalProfileUserHandle() { - return getAnnotatedUserHandles().personalProfileUserHandle; - } - - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - // @NonFinalForTesting - @Nullable - protected UserHandle getWorkProfileUserHandle() { - return getAnnotatedUserHandles().workProfileUserHandle; - } - - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - @Nullable - protected UserHandle getCloneProfileUserHandle() { - return getAnnotatedUserHandles().cloneProfileUserHandle; - } - - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - protected UserHandle getTabOwnerUserHandleForLaunch() { - return getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; - } - - protected UserHandle getUserHandleSharesheetLaunchedAs() { - return getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - } - - private boolean hasWorkProfile() { - return getWorkProfileUserHandle() != null; + return getAnnotatedUserHandles().workProfileUserHandle != null; } private boolean hasCloneProfile() { - return getCloneProfileUserHandle() != null; + return getAnnotatedUserHandles().cloneProfileUserHandle != null; } protected final boolean isLaunchedAsCloneProfile() { - return hasCloneProfile() - && getUserHandleSharesheetLaunchedAs().equals(getCloneProfileUserHandle()); + UserHandle launchUser = getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; + UserHandle cloneUser = getAnnotatedUserHandles().cloneProfileUserHandle; + return hasCloneProfile() && launchUser.equals(cloneUser); } - protected final boolean shouldShowTabs() { return hasWorkProfile(); } @@ -1368,7 +1363,9 @@ public class ResolverActivity extends FragmentActivity implements } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle())) + .setBoolean( + currentUserHandle.equals( + getAnnotatedUserHandles().personalProfileUserHandle)) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); @@ -1399,7 +1396,7 @@ public class ResolverActivity extends FragmentActivity implements } final Option optionForChooserTarget(TargetInfo target, int index) { - return new Option(target.getDisplayLabel(), index); + return new Option(getOrLoadDisplayLabel(target), index); } public final Intent getTargetIntent() { @@ -1475,8 +1472,11 @@ public class ResolverActivity extends FragmentActivity implements return getString(defaultTitleRes); } else { return named - ? getString(title.namedTitleRes, mMultiProfilePagerAdapter - .getActiveListAdapter().getFilteredItem().getDisplayLabel()) + ? getString( + title.namedTitleRes, + getOrLoadDisplayLabel( + mMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem())) : getString(title.titleRes); } } @@ -1491,15 +1491,21 @@ public class ResolverActivity extends FragmentActivity implements protected final void onRestart() { super.onRestart(); if (!mRegistered) { - mPersonalPackageMonitor.register(this, getMainLooper(), - getPersonalProfileUserHandle(), false); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + getAnnotatedUserHandles().personalProfileUserHandle, + false); if (shouldShowTabs()) { if (mWorkPackageMonitor == null) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); } - mWorkPackageMonitor.register(this, getMainLooper(), - getWorkProfileUserHandle(), false); + mWorkPackageMonitor.register( + this, + getMainLooper(), + getAnnotatedUserHandles().workProfileUserHandle, + false); } mRegistered = true; } @@ -1523,7 +1529,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override - protected final void onSaveInstanceState(Bundle outState) { + protected final void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); if (viewPager != null) { @@ -1532,7 +1538,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override - protected final void onRestoreInstanceState(Bundle savedInstanceState) { + protected final void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); resetButtonBar(); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); @@ -1807,9 +1813,10 @@ public class ResolverActivity extends FragmentActivity implements ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( getResources().getString( - inWorkProfile ? R.string.miniresolver_open_in_personal + inWorkProfile + ? R.string.miniresolver_open_in_personal : R.string.miniresolver_open_in_work, - otherProfileResolveInfo.getDisplayLabel())); + 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); @@ -1973,7 +1980,7 @@ public class ResolverActivity extends FragmentActivity implements DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) .setBoolean(activeListAdapter.getUserHandle() - .equals(getPersonalProfileUserHandle())) + .equals(getAnnotatedUserHandles().personalProfileUserHandle)) .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); @@ -2080,7 +2087,7 @@ public class ResolverActivity extends FragmentActivity implements viewPager.setVisibility(View.VISIBLE); tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); mMultiProfilePagerAdapter.setOnProfileSelectedListener( - new AbstractMultiProfilePagerAdapter.OnProfileSelectedListener() { + new MultiProfilePagerAdapter.OnProfileSelectedListener() { @Override public void onProfileSelected(int index) { tabHost.setCurrentTab(index); @@ -2256,7 +2263,7 @@ public class ResolverActivity extends FragmentActivity implements // filtered item. We always show the same default app even in the inactive user profile. boolean adapterForCurrentUserHasFilteredItem = mMultiProfilePagerAdapter.getListAdapterForUserHandle( - getTabOwnerUserHandleForLaunch()).hasFilteredItem(); + getAnnotatedUserHandles().tabOwnerUserHandleForLaunch).hasFilteredItem(); return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem; } @@ -2268,20 +2275,6 @@ public class ResolverActivity extends FragmentActivity implements mRetainInOnStop = retainInOnStop; } - /** - * Check a simple match for the component of two ResolveInfos. - */ - @Override // ResolverListCommunicator - public final boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) { - return lhs == null ? rhs == null - : lhs.activityInfo == null ? rhs.activityInfo == null - : Objects.equals(lhs.activityInfo.name, rhs.activityInfo.name) - && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName) - // Comparing against resolveInfo.userHandle in case cloned apps are present, - // as they will have the same activityInfo. - && Objects.equals(lhs.userHandle, rhs.userHandle); - } - private boolean inactiveListAdapterHasItems() { if (!shouldShowTabs()) { return false; @@ -2391,7 +2384,7 @@ public class ResolverActivity extends FragmentActivity implements * {@link ResolverListController} configured for the provided {@code userHandle}. */ protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return mLazyAnnotatedUserHandles.get().getQueryIntentsUser(userHandle); + return getAnnotatedUserHandles().getQueryIntentsUser(userHandle); } /** @@ -2411,10 +2404,18 @@ public class ResolverActivity extends FragmentActivity implements // Add clonedProfileUserHandle to the list only if we are: // a. Building the Personal Tab. // b. CloneProfile exists on the device. - if (userHandle.equals(getPersonalProfileUserHandle()) - && getCloneProfileUserHandle() != null) { - userList.add(getCloneProfileUserHandle()); + 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/src/com/android/intentresolver/ResolverInfoHelpers.kt b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt new file mode 100644 index 00000000..8d1d8658 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("ResolveInfoHelpers") + +package com.android.intentresolver + +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo + +fun resolveInfoMatch(lhs: ResolveInfo?, rhs: ResolveInfo?): Boolean = + (lhs === rhs) || + ((lhs != null && rhs != null) && + activityInfoMatch(lhs.activityInfo, rhs.activityInfo) && + // Comparing against resolveInfo.userHandle in case cloned apps are present, + // as they will have the same activityInfo. + lhs.userHandle == rhs.userHandle) + +private fun activityInfoMatch(lhs: ActivityInfo?, rhs: ActivityInfo?): Boolean = + (lhs === rhs) || + (lhs != null && rhs != null && lhs.name == rhs.name && lhs.packageName == rhs.packageName) diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 282a672f..564d8d19 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; @@ -27,6 +25,7 @@ import android.content.pm.ResolveInfo; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.AsyncTask; import android.os.RemoteException; import android.os.Trace; @@ -42,8 +41,14 @@ import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.icons.LabelInfo; import com.android.intentresolver.icons.TargetDataLoader; import com.android.internal.annotations.VisibleForTesting; @@ -53,6 +58,8 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; @@ -63,7 +70,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; @@ -75,6 +82,9 @@ public class ResolverListAdapter extends BaseAdapter { private final Set<DisplayResolveInfo> mRequestedIcons = new HashSet<>(); private final Set<DisplayResolveInfo> mRequestedLabels = new HashSet<>(); + private final Executor mBgExecutor; + private final Executor mCallbackExecutor; + private final AtomicBoolean mDestroyed = new AtomicBoolean(); private ResolveInfo mLastChosen; private DisplayResolveInfo mOtherProfile; @@ -86,7 +96,6 @@ public class ResolverListAdapter extends BaseAdapter { private int mLastChosenPosition = -1; private final boolean mFilterLastUsed; - private Runnable mPostListReadyRunnable; private boolean mIsTabLoaded; // Represents the UserSpace in which the Initial Intents should be resolved. private final UserHandle mInitialIntentsUserSpace; @@ -103,6 +112,37 @@ public class ResolverListAdapter extends BaseAdapter { ResolverListCommunicator resolverListCommunicator, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader) { + this( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + initialIntentsUserSpace, + targetDataLoader, + AsyncTask.SERIAL_EXECUTOR, + runnable -> context.getMainThreadHandler().post(runnable)); + } + + @VisibleForTesting + public ResolverListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ResolverListCommunicator resolverListCommunicator, + UserHandle initialIntentsUserSpace, + TargetDataLoader targetDataLoader, + Executor bgExecutor, + Executor callbackExecutor) { mContext = context; mIntents = payloadIntents; mInitialIntents = initialIntents; @@ -117,6 +157,12 @@ public class ResolverListAdapter extends BaseAdapter { mTargetIntent = targetIntent; mResolverListCommunicator = resolverListCommunicator; mInitialIntentsUserSpace = initialIntentsUserSpace; + mBgExecutor = bgExecutor; + mCallbackExecutor = callbackExecutor; + } + + protected Intent getTargetIntent() { + return mTargetIntent; } public final DisplayResolveInfo getFirstDisplayResolveInfo() { @@ -189,18 +235,18 @@ public class ResolverListAdapter extends BaseAdapter { packageName, userHandle, action); } - List<ResolvedComponentInfo> getUnfilteredResolveList() { + public List<ResolvedComponentInfo> getUnfilteredResolveList() { return mUnfilteredResolveList; } /** * Rebuild the list of resolvers. When rebuilding is complete, queue the {@code onPostListReady} - * callback on the main handler with {@code rebuildCompleted} true. + * callback on the callback executor with {@code rebuildCompleted} true. * * In some cases some parts will need some asynchronous work to complete. Then this will first - * immediately queue {@code onPostListReady} (on the main handler) with {@code rebuildCompleted} - * false; only when the asynchronous work completes will this then go on to queue another - * {@code onPostListReady} callback with {@code rebuildCompleted} true. + * immediately queue {@code onPostListReady} (on the callback executor) with + * {@code rebuildCompleted} false; only when the asynchronous work completes will this then go + * on to queue another {@code onPostListReady} callback with {@code rebuildCompleted} true. * * The {@code doPostProcessing} parameter is used to specify whether to update the UI and * load additional targets (e.g. direct share) after the list has been rebuilt. We may choose @@ -212,7 +258,7 @@ public class ResolverListAdapter extends BaseAdapter { * with {@code rebuildCompleted} true at the end of some newly-launched asynchronous work. * Otherwise the callback is only queued once, with {@code rebuildCompleted} true. */ - protected boolean rebuildList(boolean doPostProcessing) { + public boolean rebuildList(boolean doPostProcessing) { Trace.beginSection("ResolverListAdapter#rebuildList"); mDisplayList.clear(); mIsTabLoaded = false; @@ -357,8 +403,8 @@ public class ResolverListAdapter extends BaseAdapter { otherProfileInfo, mPm, mTargetIntent, - mResolverListCommunicator, - mTargetDataLoader); + mResolverListCommunicator + ); } else { mOtherProfile = null; try { @@ -402,35 +448,42 @@ public class ResolverListAdapter extends BaseAdapter { // Send an "incomplete" list-ready while the async task is running. postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ false); - createSortingTask(doPostProcessing).execute(filteredResolveList); + mBgExecutor.execute(() -> { + List<ResolvedComponentInfo> sortedComponents = null; + //TODO: the try-catch logic here is to formally match the AsyncTask's behavior. + // Empirically, we don't need it as in the case on an exception, the app will crash and + // `onComponentsSorted` won't be invoked. + try { + sortComponents(filteredResolveList); + sortedComponents = filteredResolveList; + } catch (Throwable t) { + Log.e(TAG, "Failed to sort components", t); + throw t; + } finally { + final List<ResolvedComponentInfo> result = sortedComponents; + mCallbackExecutor.execute(() -> onComponentsSorted(result, doPostProcessing)); + } + }); return false; } - AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>> createSortingTask(boolean doPostProcessing) { - return new AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>>() { - @Override - protected List<ResolvedComponentInfo> doInBackground( - List<ResolvedComponentInfo>... params) { - mResolverListController.sort(params[0]); - return params[0]; - } - @Override - protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) { - processSortedList(sortedComponents, doPostProcessing); - notifyDataSetChanged(); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - } - } - }; + @WorkerThread + protected void sortComponents(List<ResolvedComponentInfo> components) { + mResolverListController.sort(components); } - protected void processSortedList(List<ResolvedComponentInfo> sortedComponents, - boolean doPostProcessing) { + @MainThread + protected void onComponentsSorted( + @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { + processSortedList(sortedComponents, doPostProcessing); + notifyDataSetChanged(); + if (doPostProcessing) { + mResolverListCommunicator.updateProfileViewButton(); + } + } + + protected void processSortedList( + @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { final int n = sortedComponents != null ? sortedComponents.size() : 0; Trace.beginSection("ResolverListAdapter#processSortedList:" + n); if (n != 0) { @@ -471,8 +524,7 @@ public class ResolverListAdapter extends BaseAdapter { ri, ri.loadLabel(mPm), null, - ii, - mTargetDataLoader.createPresentationGetter(ri))); + ii)); } } @@ -494,23 +546,23 @@ public class ResolverListAdapter extends BaseAdapter { /** * Some necessary methods for creating the list are initiated in onCreate and will also * determine the layout known. We therefore can't update the UI inline and post to the - * handler thread to update after the current task is finished. + * callback executor to update after the current task is finished. * @param doPostProcessing Whether to update the UI and load additional direct share targets * after the list has been rebuilt * @param rebuildCompleted Whether the list has been completely rebuilt */ - void postListReadyRunnable(boolean doPostProcessing, boolean rebuildCompleted) { - if (mPostListReadyRunnable == null) { - mPostListReadyRunnable = new Runnable() { - @Override - public void run() { - mResolverListCommunicator.onPostListReady(ResolverListAdapter.this, - doPostProcessing, rebuildCompleted); - mPostListReadyRunnable = null; + public void postListReadyRunnable(boolean doPostProcessing, boolean rebuildCompleted) { + Runnable listReadyRunnable = new Runnable() { + @Override + public void run() { + if (mDestroyed.get()) { + return; } - }; - mContext.getMainThreadHandler().post(mPostListReadyRunnable); - } + mResolverListCommunicator.onPostListReady(ResolverListAdapter.this, + doPostProcessing, rebuildCompleted); + } + }; + mCallbackExecutor.execute(listReadyRunnable); } private void addResolveInfoWithAlternates(ResolvedComponentInfo rci) { @@ -524,8 +576,7 @@ public class ResolverListAdapter extends BaseAdapter { final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( intent, add, - (replaceIntent != null) ? replaceIntent : defaultIntent, - mTargetDataLoader.createPresentationGetter(add)); + (replaceIntent != null) ? replaceIntent : defaultIntent); dri.setPinned(rci.isPinned()); if (rci.isPinned()) { Log.i(TAG, "Pinned item: " + rci.name); @@ -572,7 +623,7 @@ public class ResolverListAdapter extends BaseAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in display. for (DisplayResolveInfo existingInfo : mDisplayList) { - if (mResolverListCommunicator + if (ResolveInfoHelpers .resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } @@ -710,27 +761,25 @@ public class ResolverListAdapter extends BaseAdapter { } } - private void loadLabel(DisplayResolveInfo info) { + protected final void loadLabel(DisplayResolveInfo info) { if (mRequestedLabels.add(info)) { mTargetDataLoader.loadLabel(info, (result) -> onLabelLoaded(info, result)); } } protected final void onLabelLoaded( - DisplayResolveInfo displayResolveInfo, CharSequence[] result) { + DisplayResolveInfo displayResolveInfo, LabelInfo result) { if (displayResolveInfo.hasDisplayLabel()) { return; } - displayResolveInfo.setDisplayLabel(result[0]); - displayResolveInfo.setExtendedInfo(result[1]); + displayResolveInfo.setDisplayLabel(result.getLabel()); + displayResolveInfo.setExtendedInfo(result.getSubLabel()); notifyDataSetChanged(); } public void onDestroy() { - if (mPostListReadyRunnable != null) { - mContext.getMainThreadHandler().removeCallbacks(mPostListReadyRunnable); - mPostListReadyRunnable = null; - } + mDestroyed.set(true); + if (mResolverListController != null) { mResolverListController.destroy(); } @@ -765,7 +814,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( @@ -777,7 +826,7 @@ public class ResolverListAdapter extends BaseAdapter { return mUserHandle; } - protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) { + public final List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) { return mResolverListController.getResolversForIntentAsUser( /* shouldGetResolvedFilter= */ true, mResolverListCommunicator.shouldGetActivityMetadata(), @@ -786,15 +835,16 @@ public class ResolverListAdapter extends BaseAdapter { userHandle); } - protected List<Intent> getIntents() { + public final List<Intent> getIntents() { + // TODO: immutable copy? return mIntents; } - protected boolean isTabLoaded() { + public boolean isTabLoaded() { return mIsTabLoaded; } - protected void markTabLoaded() { + public void markTabLoaded() { mIsTabLoaded = true; } @@ -828,8 +878,7 @@ public class ResolverListAdapter extends BaseAdapter { ResolvedComponentInfo resolvedComponentInfo, PackageManager pm, Intent targetIntent, - ResolverListCommunicator resolverListCommunicator, - TargetDataLoader targetDataLoader) { + ResolverListCommunicator resolverListCommunicator) { ResolveInfo resolveInfo = resolvedComponentInfo.getResolveInfoAt(0); Intent pOrigIntent = resolverListCommunicator.getReplacementIntent( @@ -838,25 +887,19 @@ public class ResolverListAdapter extends BaseAdapter { Intent replacementIntent = resolverListCommunicator.getReplacementIntent( resolveInfo.activityInfo, targetIntent); - TargetPresentationGetter presentationGetter = - targetDataLoader.createPresentationGetter(resolveInfo); - return DisplayResolveInfo.newDisplayResolveInfo( resolvedComponentInfo.getIntentAt(0), resolveInfo, resolveInfo.loadLabel(pm), resolveInfo.loadLabel(pm), - pOrigIntent != null ? pOrigIntent : replacementIntent, - presentationGetter); + pOrigIntent != null ? pOrigIntent : replacementIntent); } /** * Necessary methods to communicate between {@link ResolverListAdapter} * and {@link ResolverActivity}. */ - interface ResolverListCommunicator { - - boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs); + public interface ResolverListCommunicator { Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent); @@ -893,6 +936,24 @@ public class ResolverListAdapter extends BaseAdapter { public TextView text2; public ImageView icon; + public final void reset() { + text.setText(""); + text.setMaxLines(2); + text.setMaxWidth(Integer.MAX_VALUE); + text.setBackground(null); + text.setPaddingRelative(0, 0, 0, 0); + + text2.setVisibility(View.GONE); + text2.setText(""); + + itemView.setContentDescription(null); + itemView.setBackground(defaultItemViewBackground); + + icon.setImageDrawable(null); + icon.setColorFilter(null); + icon.clearAnimation(); + } + @VisibleForTesting public ViewHolder(View view) { itemView = view; @@ -937,5 +998,19 @@ public class ResolverListAdapter extends BaseAdapter { icon.setColorFilter(null); } } + + public void bindPlaceholder() { + itemView.setBackground(null); + } + + public void bindGroupIndicator(Drawable indicator) { + text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0); + text.setBackground(indicator); + } + + public void bindPinnedIndicator(Drawable indicator) { + text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0); + text.setBackground(indicator); + } } } diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index d5a5fedf..e88d766d 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -17,7 +17,6 @@ package com.android.intentresolver; -import android.annotation.WorkerThread; import android.app.ActivityManager; import android.app.AppGlobals; import android.content.ComponentName; @@ -31,6 +30,8 @@ import android.os.RemoteException; import android.os.UserHandle; import android.util.Log; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.model.AbstractResolverComparator; @@ -254,7 +255,6 @@ public class ResolverListController { isComputed = true; } - @VisibleForTesting @WorkerThread public void sort(List<ResolvedComponentInfo> inputList) { try { @@ -273,7 +273,6 @@ public class ResolverListController { } } - @VisibleForTesting @WorkerThread public void topK(List<ResolvedComponentInfo> inputList, int k) { if (inputList == null || inputList.isEmpty() || k <= 0) { @@ -335,7 +334,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 85d97ad5..591c23b7 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -24,6 +24,7 @@ import android.widget.ListView; import androidx.viewpager.widget.PagerAdapter; +import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -36,10 +37,10 @@ import java.util.function.Supplier; */ @VisibleForTesting public class ResolverMultiProfilePagerAdapter extends - GenericMultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> { + MultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> { private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ResolverMultiProfilePagerAdapter( + public ResolverMultiProfilePagerAdapter( Context context, ResolverListAdapter adapter, EmptyStateProvider emptyStateProvider, @@ -57,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), @@ -86,7 +87,6 @@ public class ResolverMultiProfilePagerAdapter extends UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { super( - context, listAdapter -> listAdapter, (listView, bindAdapter) -> listView.setAdapter(bindAdapter), listAdapters, 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/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index 645b9391..efaaf894 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.prediction.AppTarget; import android.content.Context; import android.content.Intent; @@ -26,6 +25,8 @@ import android.content.pm.ShortcutInfo; import android.service.chooser.ChooserTarget; import android.util.Log; +import androidx.annotation.Nullable; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java index ec5179ac..750b24ac 100644 --- a/java/src/com/android/intentresolver/SimpleIconFactory.java +++ b/java/src/com/android/intentresolver/SimpleIconFactory.java @@ -21,9 +21,6 @@ import static android.graphics.Paint.DITHER_FLAG; import static android.graphics.Paint.FILTER_BITMAP_FLAG; import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction; -import android.annotation.AttrRes; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.ActivityManager; import android.content.Context; import android.content.pm.PackageManager; @@ -50,6 +47,10 @@ import android.util.AttributeSet; import android.util.Pools.SynchronizedPool; import android.util.TypedValue; +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.internal.annotations.VisibleForTesting; import org.xmlpull.v1.XmlPullParser; @@ -719,10 +720,18 @@ public class SimpleIconFactory { } @Override - public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs) { } + public void inflate( + @NonNull Resources r, + @NonNull XmlPullParser parser, + @NonNull AttributeSet attrs) { + } @Override - public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs, Theme theme) { } + public void inflate( + @NonNull Resources r, + @NonNull XmlPullParser parser, + @NonNull AttributeSet attrs, Theme theme) { + } /** * Sets the scale associated with this drawable diff --git a/java/src/com/android/intentresolver/TargetPresentationGetter.java b/java/src/com/android/intentresolver/TargetPresentationGetter.java index f8b36566..910c65c9 100644 --- a/java/src/com/android/intentresolver/TargetPresentationGetter.java +++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.content.Context; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; @@ -30,6 +29,8 @@ import android.os.UserHandle; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.Nullable; + /** * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application icon * and label over any IntentFilter or Activity icon to increase user understanding, with an @@ -37,7 +38,7 @@ import android.util.Log; * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses * Strings to strip creative formatting. * - * Use one of the {@link TargetPresentationGetter#Factory} methods to create an instance of the + * Use one of the {@link TargetPresentationGetter.Factory} methods to create an instance of the * appropriate concrete type. * * TODO: once this component (and its tests) are merged, it should be possible to refactor and diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java index 8b9bfb32..074537ef 100644 --- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -16,6 +16,8 @@ package com.android.intentresolver.chooser; +import android.service.chooser.ChooserTarget; + import java.util.ArrayList; import java.util.Arrays; diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 09cf319f..536f11ce 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -16,8 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.Activity; import android.content.ComponentName; import android.content.Intent; @@ -27,10 +25,10 @@ import android.content.pm.ResolveInfo; import android.os.Bundle; import android.os.UserHandle; -import com.android.intentresolver.TargetPresentationGetter; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; /** @@ -39,12 +37,11 @@ import java.util.List; */ public class DisplayResolveInfo implements TargetInfo { private final ResolveInfo mResolveInfo; - private CharSequence mDisplayLabel; - private CharSequence mExtendedInfo; + private volatile CharSequence mDisplayLabel; + private volatile CharSequence mExtendedInfo; private final Intent mResolvedIntent; private final List<Intent> mSourceIntents = new ArrayList<>(); private final boolean mIsSuspended; - private TargetPresentationGetter mPresentationGetter; private boolean mPinned = false; private final IconHolder mDisplayIconHolder = new SettableIconHolder(); @@ -52,15 +49,13 @@ public class DisplayResolveInfo implements TargetInfo { public static DisplayResolveInfo newDisplayResolveInfo( Intent originalIntent, ResolveInfo resolveInfo, - @NonNull Intent resolvedIntent, - @Nullable TargetPresentationGetter presentationGetter) { + @NonNull Intent resolvedIntent) { return newDisplayResolveInfo( originalIntent, resolveInfo, /* displayLabel=*/ null, /* extendedInfo=*/ null, - resolvedIntent, - presentationGetter); + resolvedIntent); } /** Create a new {@code DisplayResolveInfo} instance. */ @@ -69,15 +64,13 @@ public class DisplayResolveInfo implements TargetInfo { ResolveInfo resolveInfo, CharSequence displayLabel, CharSequence extendedInfo, - @NonNull Intent resolvedIntent, - @Nullable TargetPresentationGetter presentationGetter) { + @NonNull Intent resolvedIntent) { return new DisplayResolveInfo( originalIntent, resolveInfo, displayLabel, extendedInfo, - resolvedIntent, - presentationGetter); + resolvedIntent); } private DisplayResolveInfo( @@ -85,13 +78,11 @@ public class DisplayResolveInfo implements TargetInfo { ResolveInfo resolveInfo, CharSequence displayLabel, CharSequence extendedInfo, - @NonNull Intent resolvedIntent, - @Nullable TargetPresentationGetter presentationGetter) { + @NonNull Intent resolvedIntent) { mSourceIntents.add(originalIntent); mResolveInfo = resolveInfo; mDisplayLabel = displayLabel; mExtendedInfo = extendedInfo; - mPresentationGetter = presentationGetter; final ActivityInfo ai = mResolveInfo.activityInfo; mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; @@ -101,8 +92,7 @@ public class DisplayResolveInfo implements TargetInfo { private DisplayResolveInfo( DisplayResolveInfo other, - @Nullable Intent baseIntentToSend, - TargetPresentationGetter presentationGetter) { + @Nullable Intent baseIntentToSend) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; mIsSuspended = other.mIsSuspended; @@ -112,7 +102,6 @@ public class DisplayResolveInfo implements TargetInfo { mResolvedIntent = createResolvedIntent( baseIntentToSend == null ? other.mResolvedIntent : baseIntentToSend, mResolveInfo.activityInfo); - mPresentationGetter = presentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } @@ -124,7 +113,6 @@ public class DisplayResolveInfo implements TargetInfo { mDisplayLabel = other.mDisplayLabel; mExtendedInfo = other.mExtendedInfo; mResolvedIntent = other.mResolvedIntent; - mPresentationGetter = other.mPresentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } @@ -147,10 +135,6 @@ public class DisplayResolveInfo implements TargetInfo { } public CharSequence getDisplayLabel() { - if (mDisplayLabel == null && mPresentationGetter != null) { - mDisplayLabel = mPresentationGetter.getLabel(); - mExtendedInfo = mPresentationGetter.getSubLabel(); - } return mDisplayLabel; } @@ -186,8 +170,7 @@ public class DisplayResolveInfo implements TargetInfo { return new DisplayResolveInfo( this, - TargetInfo.mergeRefinementIntoMatchingBaseIntent(matchingBase, proposedRefinement), - mPresentationGetter); + TargetInfo.mergeRefinementIntoMatchingBaseIntent(matchingBase, proposedRefinement)); } @Override @@ -197,7 +180,7 @@ public class DisplayResolveInfo implements TargetInfo { @Override public ArrayList<DisplayResolveInfo> getAllDisplayTargets() { - return new ArrayList<>(Arrays.asList(this)); + return new ArrayList<>(List.of(this)); } public void addAlternateSourceIntent(Intent alt) { diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java index 10d4415a..50aaec0b 100644 --- a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java @@ -16,8 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; import android.content.ComponentName; @@ -27,8 +25,11 @@ import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.os.Bundle; import android.os.UserHandle; +import android.service.chooser.ChooserTarget; import android.util.HashedStringCache; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -43,7 +44,7 @@ import java.util.List; public final class ImmutableTargetInfo implements TargetInfo { private static final String TAG = "TargetInfo"; - /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics()}. */ + /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics}. */ public interface TargetHashProvider { /** Request a hash for the specified {@code target}. */ HashedStringCache.HashResult getHashedTargetIdForMetrics( @@ -53,15 +54,15 @@ public final class ImmutableTargetInfo implements TargetInfo { /** Delegate interface to request that the target be launched by a particular API. */ public interface TargetActivityStarter { /** - * Request that the delegate use the {@link Activity#startAsCaller()} API to launch the - * specified {@code target}. + * Request that the delegate use the {@link Activity#startActivityAsCaller} API to launch + * the specified {@code target}. * * @return true if the target was launched successfully. */ boolean startAsCaller(TargetInfo target, Activity activity, Bundle options, int userId); /** - * Request that the delegate use the {@link Activity#startAsUser()} API to launch the + * Request that the delegate use the {@link Activity#startActivityAsUser} API to launch the * specified {@code target}. * * @return true if the target was launched successfully. @@ -145,7 +146,7 @@ public final class ImmutableTargetInfo implements TargetInfo { /** * Configure an {@link Intent} to be built in to the output target as the "base intent to * send," which may be a refinement of any of our source targets. This is private because - * it's only used internally by {@link #tryToCloneWithAppliedRefinement()}; if it's ever + * it's only used internally by {@link #tryToCloneWithAppliedRefinement}; if it's ever * expanded, the builder should probably be responsible for enforcing the refinement check. */ private Builder setBaseIntentToSend(Intent baseIntent) { @@ -229,8 +230,8 @@ public final class ImmutableTargetInfo implements TargetInfo { /** * Configure the full list of source intents we could resolve for this target. This is - * effectively the same as calling {@link #setResolvedIntent()} with the first element of - * the list, and {@link #setAlternateSourceIntents()} with the remainder (or clearing those + * effectively the same as calling {@link #setResolvedIntent} with the first element of + * the list, and {@link #setAlternateSourceIntents} with the remainder (or clearing those * fields on the builder if there are no corresponding elements in the list). */ public Builder setAllSourceIntents(List<Intent> sourceIntents) { diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 6444e13b..46803a04 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -16,7 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.app.Activity; import android.content.Context; import android.graphics.drawable.AnimatedVectorDrawable; @@ -24,6 +23,8 @@ import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; +import androidx.annotation.Nullable; + import com.android.intentresolver.R; import java.util.function.Supplier; diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 5766db0e..c4aa9021 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -16,7 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; import android.content.ComponentName; @@ -33,6 +32,8 @@ import android.text.SpannableStringBuilder; import android.util.HashedStringCache; import android.util.Log; +import androidx.annotation.Nullable; + import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 9d793994..ba6c3c05 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -17,14 +17,15 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; @@ -32,6 +33,12 @@ import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.HashedStringCache; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRefinementManager; +import com.android.intentresolver.ResolverActivity; + import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -187,9 +194,9 @@ public interface TargetInfo { * Attempt to apply a {@code proposedRefinement} that the {@link ChooserRefinementManager} * received from the caller's refinement flow. This may succeed only if the target has a source * intent that matches the filtering parameters of the proposed refinement (according to - * {@link Intent#filterEquals()}). Then the first such match is the "base intent," and the - * proposed refinement is merged into that base (via {@link Intent#fillIn()}; this can never - * result in a change to the {@link Intent#filterEquals()} status of the base, but may e.g. add + * {@link Intent#filterEquals}). Then the first such match is the "base intent," and the + * proposed refinement is merged into that base (via {@link Intent#fillIn}; this can never + * result in a change to the {@link Intent#filterEquals} status of the base, but may e.g. add * new "extras" that weren't previously given in the base intent). * * @return a copy of this {@link TargetInfo} where the "base intent to send" is the result of @@ -280,7 +287,7 @@ public interface TargetInfo { } /** - * @return the {@link ShortcutManager} data for any shortcut associated with this target. + * @return the {@link ShortcutInfo} for any shortcut associated with this target. */ @Nullable default ShortcutInfo getDirectShareShortcutInfo() { @@ -422,7 +429,7 @@ public interface TargetInfo { /** * @return true if this target should be logged with the "direct_share" metrics category in - * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch()}. This is defined for legacy + * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch}. This is defined for legacy * compatibility and is <em>not</em> likely to be a good indicator of whether this is actually a * "direct share" target (e.g. because it historically also applies to "empty" and "placeholder" * targets). diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 103e8bf4..10ee5af1 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview +import android.content.Intent import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import com.android.intentresolver.ChooserRequestParameters @@ -24,7 +25,7 @@ import com.android.intentresolver.ChooserRequestParameters abstract class BasePreviewViewModel : ViewModel() { @MainThread abstract fun createOrReuseProvider( - chooserRequest: ChooserRequestParameters + targetIntent: Intent ): PreviewDataProvider @MainThread abstract fun createOrReuseImageLoader(): ImageLoader diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index d279f11f..a015147d 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -16,8 +16,6 @@ package com.android.intentresolver.contentpreview; -import static androidx.lifecycle.LifecycleKt.getCoroutineScope; - import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; @@ -28,11 +26,11 @@ import android.content.res.Resources; import android.net.Uri; import android.text.TextUtils; import android.view.LayoutInflater; +import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import androidx.lifecycle.Lifecycle; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; @@ -40,6 +38,8 @@ import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatu import java.util.List; import java.util.function.Consumer; +import kotlinx.coroutines.CoroutineScope; + /** * Collection of helpers for building the content preview UI displayed in * {@link com.android.intentresolver.ChooserActivity}. @@ -47,7 +47,7 @@ import java.util.function.Consumer; */ public final class ChooserContentPreviewUi { - private final Lifecycle mLifecycle; + private final CoroutineScope mScope; /** * Delegate to build the default system action buttons to display in the preview layout, if/when @@ -92,14 +92,14 @@ public final class ChooserContentPreviewUi { final ContentPreviewUi mContentPreviewUi; public ChooserContentPreviewUi( - Lifecycle lifecycle, + CoroutineScope scope, PreviewDataProvider previewData, Intent targetIntent, ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator) { - mLifecycle = lifecycle; + mScope = scope; mContentPreviewUi = createContentPreview( previewData, targetIntent, @@ -125,7 +125,7 @@ public final class ChooserContentPreviewUi { int previewType = previewData.getPreviewType(); if (previewType == CONTENT_PREVIEW_TEXT) { return createTextPreview( - mLifecycle, + mScope, targetIntent, actionFactory, imageLoader, @@ -137,8 +137,7 @@ public final class ChooserContentPreviewUi { actionFactory, headlineGenerator); if (previewData.getUriCount() > 0) { - previewData.getFirstFileName( - mLifecycle, fileContentPreviewUi::setFirstFileName); + previewData.getFirstFileName(mScope, fileContentPreviewUi::setFirstFileName); } return fileContentPreviewUi; } @@ -148,7 +147,7 @@ public final class ChooserContentPreviewUi { if (!TextUtils.isEmpty(text)) { FilesPlusTextContentPreviewUi previewUi = new FilesPlusTextContentPreviewUi( - mLifecycle, + mScope, isSingleImageShare, previewData.getUriCount(), targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), @@ -159,7 +158,7 @@ public final class ChooserContentPreviewUi { headlineGenerator); if (previewData.getUriCount() > 0) { JavaFlowHelper.collectToList( - getCoroutineScope(mLifecycle), + mScope, previewData.getImagePreviewFileInfoFlow(), previewUi::updatePreviewMetadata); } @@ -167,7 +166,7 @@ public final class ChooserContentPreviewUi { } return new UnifiedContentPreviewUi( - getCoroutineScope(mLifecycle), + mScope, isSingleImageShare, targetIntent.getType(), actionFactory, @@ -188,19 +187,22 @@ public final class ChooserContentPreviewUi { * specified {@code intent}. */ public ViewGroup displayContentPreview( - Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + @Nullable View headlineViewParent) { - return mContentPreviewUi.display(resources, layoutInflater, parent); + return mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent); } private static TextContentPreviewUi createTextPreview( - Lifecycle lifecycle, + CoroutineScope scope, Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, HeadlineGenerator headlineGenerator) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); + CharSequence previewTitle = targetIntent.getCharSequenceExtra(Intent.EXTRA_TITLE); ClipData previewData = targetIntent.getClipData(); Uri previewThumbnail = null; if (previewData != null) { @@ -210,7 +212,7 @@ public final class ChooserContentPreviewUi { } } return new TextContentPreviewUi( - lifecycle, + scope, sharingText, previewTitle, previewThumbnail, diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java index ebab147d..ad1c6c01 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java @@ -18,7 +18,7 @@ package com.android.intentresolver.contentpreview; import static java.lang.annotation.RetentionPolicy.SOURCE; -import android.annotation.IntDef; +import androidx.annotation.IntDef; import java.lang.annotation.Retention; diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 2d81794e..dce146b0 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -24,10 +24,13 @@ import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.ViewStub; import android.view.animation.DecelerateInterpolator; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.Nullable; + import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; @@ -40,7 +43,10 @@ abstract class ContentPreviewUi { public abstract int getType(); public abstract ViewGroup display( - Resources resources, LayoutInflater layoutInflater, ViewGroup parent); + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + @Nullable View headlineViewParent); protected static void updateViewWithImage(ImageView imageView, Bitmap image) { if (image == null) { @@ -57,23 +63,28 @@ abstract class ContentPreviewUi { fadeAnim.start(); } - protected static void displayHeadline(ViewGroup layout, String headline) { - if (layout != null) { - TextView titleView = layout.findViewById(R.id.headline); - if (titleView != null) { - if (!TextUtils.isEmpty(headline)) { - titleView.setText(headline); - titleView.setVisibility(View.VISIBLE); - } else { - titleView.setVisibility(View.GONE); - } - } + protected static void inflateHeadline(View layout) { + ViewStub stub = layout.findViewById(R.id.chooser_headline_row_stub); + if (stub != null) { + stub.inflate(); + } + } + + protected static void displayHeadline(View layout, String headline) { + TextView titleView = layout == null ? null : layout.findViewById(R.id.headline); + if (titleView == null) { + return; + } + if (!TextUtils.isEmpty(headline)) { + titleView.setText(headline); + titleView.setVisibility(View.VISIBLE); + } else { + titleView.setVisibility(View.GONE); } } protected static void displayModifyShareAction( - ViewGroup layout, - ChooserContentPreviewUi.ActionFactory actionFactory) { + View layout, ChooserContentPreviewUi.ActionFactory actionFactory) { ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction(); if (modifyShareAction != null && layout != null) { TextView modifyShareView = layout.findViewById(R.id.reselection_action); diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 20758189..89e7e528 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -67,18 +67,30 @@ class FileContentPreviewUi extends ContentPreviewUi { } @Override - public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = displayInternal(resources, layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory); + public ViewGroup display( + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + @Nullable View headlineViewParent) { + ViewGroup layout = displayInternal(resources, layoutInflater, parent, headlineViewParent); + displayModifyShareAction( + headlineViewParent == null ? layout : headlineViewParent, mActionFactory); return layout; } private ViewGroup displayInternal( - Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + @Nullable View headlineViewParent) { mContentPreview = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); + if (headlineViewParent == null) { + headlineViewParent = mContentPreview; + } + inflateHeadline(headlineViewParent); - displayHeadline(mContentPreview, mHeadlineGenerator.getFilesHeadline(mFileCount)); + displayHeadline(headlineViewParent, mHeadlineGenerator.getFilesHeadline(mFileCount)); if (mFileCount == 0) { mContentPreview.setVisibility(View.GONE); diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 6e1212e9..78fc6586 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -31,7 +31,6 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; @@ -41,6 +40,8 @@ import java.util.HashMap; import java.util.List; import java.util.function.Consumer; +import kotlinx.coroutines.CoroutineScope; + /** * FilesPlusTextContentPreviewUi is shown when the user is sending 1 or more files along with * non-empty EXTRA_TEXT. The text can be toggled with a checkbox. If a single image file is being @@ -48,7 +49,7 @@ import java.util.function.Consumer; * file content). */ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { - private final Lifecycle mLifecycle; + private final CoroutineScope mScope; @Nullable private final String mIntentMimeType; private final CharSequence mText; @@ -59,6 +60,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private final boolean mIsSingleImage; private final int mFileCount; private ViewGroup mContentPreviewView; + private View mHeadliveView; private boolean mIsMetadataUpdated = false; @Nullable private Uri mFirstFilePreviewUri; @@ -68,7 +70,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private static final boolean SHOW_TOGGLE_CHECKMARK = false; FilesPlusTextContentPreviewUi( - Lifecycle lifecycle, + CoroutineScope scope, boolean isSingleImage, int fileCount, CharSequence text, @@ -81,7 +83,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { throw new IllegalArgumentException( "fileCount = " + fileCount + " and isSingleImage = true"); } - mLifecycle = lifecycle; + mScope = scope; mIntentMimeType = intentMimeType; mFileCount = fileCount; mIsSingleImage = isSingleImage; @@ -98,9 +100,14 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } @Override - public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory); + public ViewGroup display( + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + @Nullable View headlineViewParent) { + ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); + displayModifyShareAction( + headlineViewParent == null ? layout : headlineViewParent, mActionFactory); return layout; } @@ -118,13 +125,18 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { mFirstFilePreviewUri = files.isEmpty() ? null : files.get(0).getPreviewUri(); mIsMetadataUpdated = true; if (mContentPreviewView != null) { - updateUiWithMetadata(mContentPreviewView); + updateUiWithMetadata(mContentPreviewView, mHeadliveView); } } - private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + private ViewGroup displayInternal( + LayoutInflater layoutInflater, + ViewGroup parent, + @Nullable View headlineViewParent) { mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_files_text, parent, false); + mHeadliveView = headlineViewParent == null ? mContentPreviewView : headlineViewParent; + inflateHeadline(mHeadliveView); final ActionRow actionRow = mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row); @@ -134,12 +146,12 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { if (!mIsSingleImage) { mContentPreviewView.requireViewById(R.id.image_view).setVisibility(View.GONE); } - prepareTextPreview(mContentPreviewView, mActionFactory); + prepareTextPreview(mContentPreviewView, mHeadliveView, mActionFactory); if (mIsMetadataUpdated) { - updateUiWithMetadata(mContentPreviewView); + updateUiWithMetadata(mContentPreviewView, mHeadliveView); } else { updateHeadline( - mContentPreviewView, + mHeadliveView, mFileCount, mTypeClassifier.isImageType(mIntentMimeType), mTypeClassifier.isVideoType(mIntentMimeType)); @@ -148,13 +160,14 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { return mContentPreviewView; } - private void updateUiWithMetadata(ViewGroup contentPreviewView) { - updateHeadline(contentPreviewView, mFileCount, mAllImages, mAllVideos); + private void updateUiWithMetadata(ViewGroup contentPreviewView, View headlineView) { + prepareTextPreview(contentPreviewView, headlineView, mActionFactory); + updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos); ImageView imagePreview = mContentPreviewView.requireViewById(R.id.image_view); if (mIsSingleImage && mFirstFilePreviewUri != null) { mImageLoader.loadImage( - mLifecycle, + mScope, mFirstFilePreviewUri, bitmap -> { if (bitmap == null) { @@ -169,8 +182,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } private void updateHeadline( - ViewGroup contentPreview, int fileCount, boolean allImages, boolean allVideos) { - CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action); + View headlineView, int fileCount, boolean allImages, boolean allVideos) { + CheckBox includeText = headlineView.requireViewById(R.id.include_text_action); String headline; if (includeText.getVisibility() == View.VISIBLE && includeText.isChecked()) { if (allImages) { @@ -190,14 +203,15 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } } - displayHeadline(contentPreview, headline); + displayHeadline(headlineView, headline); } private void prepareTextPreview( ViewGroup contentPreview, + View headlineView, ChooserContentPreviewUi.ActionFactory actionFactory) { final TextView textView = contentPreview.requireViewById(R.id.content_preview_text); - CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action); + CheckBox includeText = headlineView.requireViewById(R.id.include_text_action); boolean isLink = HttpUriMatcher.isHttpUri(mText.toString()); textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); textView.setText(mText); @@ -213,7 +227,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { textView.setText(getNoTextString(contentPreview.getResources())); } shareTextAction.accept(!isChecked); - updateHeadline(contentPreview, mFileCount, mAllImages, mAllVideos); + updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos); }); if (SHOW_TOGGLE_CHECKMARK) { includeText.setVisibility(View.VISIBLE); diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index 1aace8c3..ef1e55d8 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -16,36 +16,55 @@ package com.android.intentresolver.contentpreview -import android.annotation.StringRes import android.content.Context -import com.android.intentresolver.R import android.util.PluralsMessageFormatter +import androidx.annotation.StringRes +import com.android.intentresolver.R private const val PLURALS_COUNT = "count" /** - * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief - * description of the content being shared. + * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description + * of the content being shared. */ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { override fun getTextHeadline(text: CharSequence): String { return context.getString( - getTemplateResource(text, R.string.sharing_link, R.string.sharing_text)) + getTemplateResource(text, R.string.sharing_link, R.string.sharing_text) + ) } override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String { - return getPluralString(getTemplateResource( - text, R.string.sharing_images_with_link, R.string.sharing_images_with_text), count) + return getPluralString( + getTemplateResource( + text, + R.string.sharing_images_with_link, + R.string.sharing_images_with_text + ), + count + ) } override fun getVideosWithTextHeadline(text: CharSequence, count: Int): String { - return getPluralString(getTemplateResource( - text, R.string.sharing_videos_with_link, R.string.sharing_videos_with_text), count) + return getPluralString( + getTemplateResource( + text, + R.string.sharing_videos_with_link, + R.string.sharing_videos_with_text + ), + count + ) } override fun getFilesWithTextHeadline(text: CharSequence, count: Int): String { - return getPluralString(getTemplateResource( - text, R.string.sharing_files_with_link, R.string.sharing_files_with_text), count) + return getPluralString( + getTemplateResource( + text, + R.string.sharing_files_with_link, + R.string.sharing_files_with_text + ), + count + ) } override fun getImagesHeadline(count: Int): String { @@ -70,7 +89,9 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { @StringRes private fun getTemplateResource( - text: CharSequence, @StringRes linkResource: Int, @StringRes nonLinkResource: Int + text: CharSequence, + @StringRes linkResource: Int, + @StringRes nonLinkResource: Int ): Int { return if (text.toString().isHttpUri()) linkResource else nonLinkResource } diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index 8d0fb84b..629651a3 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -18,8 +18,8 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri -import androidx.lifecycle.Lifecycle import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope /** A content preview image loader. */ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? { @@ -30,7 +30,7 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm * @param callback a callback that will be invoked with the loaded image or null if loading has * failed. */ - fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer<Bitmap?>) + fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) /** Prepopulate the image loader cache. */ fun prePopulate(uris: List<Uri>) diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt index 22dd1125..572ccf0b 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -24,8 +24,6 @@ import android.util.Size import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.collection.LruCache -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope import java.util.function.Consumer import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -70,8 +68,8 @@ constructor( override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching) - override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer<Bitmap?>) { - callerLifecycle.coroutineScope.launch { + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { + callerScope.launch { val image = loadImageAsync(uri, caching = true) if (isActive) { callback.accept(image) diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt index 90016932..31a7006c 100644 --- a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt @@ -19,13 +19,17 @@ package com.android.intentresolver.contentpreview import android.content.res.Resources import android.util.Log import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() { override fun getType(): Int = type override fun display( - resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup? + resources: Resources?, + layoutInflater: LayoutInflater?, + parent: ViewGroup?, + headlineViewParent: View?, ): ViewGroup? { Log.e(TAG, "Unexpected content preview type: $type") return null diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 9f1cc6c1..38918d79 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -29,8 +29,6 @@ import android.text.TextUtils import android.util.Log import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT @@ -185,11 +183,11 @@ constructor( * is not provided, derived from the URI. */ @Throws(IndexOutOfBoundsException::class) - fun getFirstFileName(callerLifecycle: Lifecycle, callback: Consumer<String>) { + fun getFirstFileName(callerScope: CoroutineScope, callback: Consumer<String>) { if (records.isEmpty()) { throw IndexOutOfBoundsException("There are no shared URIs") } - callerLifecycle.coroutineScope.launch { + callerScope.launch { val result = scope.async { getFirstFileName() }.await() callback.accept(result) } @@ -264,44 +262,46 @@ constructor( private val query by lazy { readQueryResult() } - private fun readQueryResult(): QueryResult { - val cursor = - contentResolver.querySafe(uri)?.takeIf { it.moveToFirst() } ?: return QueryResult() - - var flagColIdx = -1 - var displayIconUriColIdx = -1 - var nameColIndex = -1 - var titleColIndex = -1 - // TODO: double-check why Cursor#getColumnInded didn't work - cursor.columnNames.forEachIndexed { i, columnName -> - when (columnName) { - DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i - MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i - OpenableColumns.DISPLAY_NAME -> nameColIndex = i - Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + private fun readQueryResult(): QueryResult = + contentResolver.querySafe(uri)?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + + var flagColIdx = -1 + var displayIconUriColIdx = -1 + var nameColIndex = -1 + var titleColIndex = -1 + // TODO: double-check why Cursor#getColumnInded didn't work + cursor.columnNames.forEachIndexed { i, columnName -> + when (columnName) { + DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i + MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i + OpenableColumns.DISPLAY_NAME -> nameColIndex = i + Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + } } - } - - val supportsThumbnail = - flagColIdx >= 0 && ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) - var title = "" - if (nameColIndex >= 0) { - title = cursor.getString(nameColIndex) ?: "" - } - if (TextUtils.isEmpty(title) && titleColIndex >= 0) { - title = cursor.getString(titleColIndex) ?: "" - } + val supportsThumbnail = + flagColIdx >= 0 && + ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) - val iconUri = - if (displayIconUriColIdx >= 0) { - cursor.getString(displayIconUriColIdx)?.let(Uri::parse) - } else { - null + var title = "" + if (nameColIndex >= 0) { + title = cursor.getString(nameColIndex) ?: "" + } + if (TextUtils.isEmpty(title) && titleColIndex >= 0) { + title = cursor.getString(titleColIndex) ?: "" } - return QueryResult(supportsThumbnail, title, iconUri) - } + val iconUri = + if (displayIconUriColIdx >= 0) { + cursor.getString(displayIconUriColIdx)?.let(Uri::parse) + } else { + null + } + + QueryResult(supportsThumbnail, title, iconUri) + } + ?: QueryResult() } private class QueryResult( diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 6013f5a0..6350756e 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview import android.app.Application +import android.content.Intent import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -25,26 +26,32 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.ChooserRequestParameters import com.android.intentresolver.R +import com.android.intentresolver.inject.Background +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.plus /** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */ -class PreviewViewModel( +@HiltViewModel +class PreviewViewModel +@Inject +constructor( private val application: Application, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BasePreviewViewModel() { private var previewDataProvider: PreviewDataProvider? = null private var imageLoader: ImagePreviewImageLoader? = null @MainThread override fun createOrReuseProvider( - chooserRequest: ChooserRequestParameters + targetIntent: Intent ): PreviewDataProvider = previewDataProvider ?: PreviewDataProvider( viewModelScope + dispatcher, - chooserRequest.targetIntent, + targetIntent, application.contentResolver ) .also { previewDataProvider = it } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index c38ed03a..b0dc3c58 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -20,6 +20,7 @@ import static com.android.intentresolver.util.UriFilters.isOwnedByCurrentUser; import android.content.res.Resources; import android.net.Uri; +import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; @@ -28,13 +29,14 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; +import kotlinx.coroutines.CoroutineScope; + class TextContentPreviewUi extends ContentPreviewUi { - private final Lifecycle mLifecycle; + private final CoroutineScope mScope; @Nullable private final CharSequence mSharingText; @Nullable @@ -46,14 +48,14 @@ class TextContentPreviewUi extends ContentPreviewUi { private final HeadlineGenerator mHeadlineGenerator; TextContentPreviewUi( - Lifecycle lifecycle, + CoroutineScope scope, @Nullable CharSequence sharingText, @Nullable CharSequence previewTitle, @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, HeadlineGenerator headlineGenerator) { - mLifecycle = lifecycle; + mScope = scope; mSharingText = sharingText; mPreviewTitle = previewTitle; mPreviewThumbnail = previewThumbnail; @@ -68,17 +70,27 @@ class TextContentPreviewUi extends ContentPreviewUi { } @Override - public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory); + public ViewGroup display( + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + @Nullable View headlineViewParent) { + ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); + displayModifyShareAction( + headlineViewParent == null ? layout : headlineViewParent, mActionFactory); return layout; } private ViewGroup displayInternal( LayoutInflater layoutInflater, - ViewGroup parent) { + ViewGroup parent, + @Nullable View headlineViewParent) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); + if (headlineViewParent == null) { + headlineViewParent = contentPreviewLayout; + } + inflateHeadline(headlineViewParent); final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); @@ -93,13 +105,9 @@ class TextContentPreviewUi extends ContentPreviewUi { TextView textView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_text); - String text = mSharingText.toString(); - // If we're only previewing one line, then strip out newlines. - if (textView.getMaxLines() == 1) { - text = text.replace("\n", " "); - } - textView.setText(text); + textView.setText( + textView.getMaxLines() == 1 ? replaceLineBreaks(mSharingText) : mSharingText); TextView previewTitleView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_title); @@ -115,7 +123,7 @@ class TextContentPreviewUi extends ContentPreviewUi { previewThumbnailView.setVisibility(View.GONE); } else { mImageLoader.loadImage( - mLifecycle, + mScope, mPreviewThumbnail, (bitmap) -> updateViewWithImage( contentPreviewLayout.findViewById( @@ -131,8 +139,22 @@ class TextContentPreviewUi extends ContentPreviewUi { copyButton.setVisibility(View.GONE); } - displayHeadline(contentPreviewLayout, mHeadlineGenerator.getTextHeadline(mSharingText)); + displayHeadline(headlineViewParent, mHeadlineGenerator.getTextHeadline(mSharingText)); return contentPreviewLayout; } + + @Nullable + private static CharSequence replaceLineBreaks(@Nullable CharSequence text) { + if (text == null) { + return null; + } + SpannableStringBuilder string = new SpannableStringBuilder(text); + for (int i = 0, size = string.length(); i < size; i++) { + if (string.charAt(i) == '\n') { + string.replace(i, i + 1, " "); + } + } + return string; + } } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 8e635aba..8ddd5273 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -52,6 +52,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { private List<FileInfo> mFiles; @Nullable private ViewGroup mContentPreviewView; + @Nullable + private View mHeadlineView; UnifiedContentPreviewUi( CoroutineScope scope, @@ -83,9 +85,14 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } @Override - public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory); + public ViewGroup display( + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + @Nullable View headlineViewParent) { + ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); + displayModifyShareAction( + headlineViewParent == null ? layout : headlineViewParent, mActionFactory); return layout; } @@ -96,13 +103,16 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { .toList()); mFiles = files; if (mContentPreviewView != null) { - updatePreviewWithFiles(mContentPreviewView, files); + updatePreviewWithFiles(mContentPreviewView, mHeadlineView, files); } } - private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + private ViewGroup displayInternal( + LayoutInflater layoutInflater, ViewGroup parent, @Nullable View headlineViewParent) { mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); + mHeadlineView = headlineViewParent == null ? mContentPreviewView : headlineViewParent; + inflateHeadline(mHeadlineView); final ActionRow actionRow = mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row); @@ -122,10 +132,10 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mItemCount); if (mFiles != null) { - updatePreviewWithFiles(mContentPreviewView, mFiles); + updatePreviewWithFiles(mContentPreviewView, mHeadlineView, mFiles); } else { displayHeadline( - mContentPreviewView, + mHeadlineView, mItemCount, mTypeClassifier.isImageType(mIntentMimeType), mTypeClassifier.isVideoType(mIntentMimeType)); @@ -135,7 +145,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return mContentPreviewView; } - private void updatePreviewWithFiles(ViewGroup contentPreviewView, List<FileInfo> files) { + private void updatePreviewWithFiles( + ViewGroup contentPreviewView, View headlineView, List<FileInfo> files) { final int count = files.size(); ScrollableImagePreviewView imagePreview = contentPreviewView.requireViewById(R.id.scrollable_image_preview); @@ -158,11 +169,11 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; } - displayHeadline(contentPreviewView, count, allImages, allVideos); + displayHeadline(headlineView, count, allImages, allVideos); } private void displayHeadline( - ViewGroup layout, int count, boolean allImages, boolean allVideos) { + View layout, int count, boolean allImages, boolean allVideos) { if (allImages) { displayHeadline(layout, mHeadlineGenerator.getImagesHeadline(count)); } else if (allVideos) { diff --git a/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java new file mode 100644 index 00000000..41422b66 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate; + +import android.annotation.Nullable; + +import com.android.intentresolver.ResolverListAdapter; + +/** + * Empty state provider that combines multiple providers. Providers earlier in the list have + * priority, that is if there is a provider that returns non-null empty state then all further + * providers will be ignored. + */ +public class CompositeEmptyStateProvider implements EmptyStateProvider { + + private final EmptyStateProvider[] mProviders; + + public CompositeEmptyStateProvider(EmptyStateProvider... providers) { + mProviders = providers; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + for (EmptyStateProvider provider : mProviders) { + EmptyState emptyState = provider.getEmptyState(resolverListAdapter); + if (emptyState != null) { + return emptyState; + } + } + return null; + } +} diff --git a/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java b/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java new file mode 100644 index 00000000..2164e533 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate; + +import android.annotation.NonNull; +import android.annotation.UserIdInt; +import android.app.AppGlobals; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.pm.IPackageManager; + +import com.android.intentresolver.IntentForwarderActivity; + +import java.util.List; + +/** + * Utility class to check if there are cross profile intents, it is in a separate class so + * it could be mocked in tests + */ +public class CrossProfileIntentsChecker { + + private final ContentResolver mContentResolver; + private final IPackageManager mPackageManager; + + public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { + this(contentResolver, AppGlobals.getPackageManager()); + } + + CrossProfileIntentsChecker( + @NonNull ContentResolver contentResolver, IPackageManager packageManager) { + mContentResolver = contentResolver; + mPackageManager = packageManager; + } + + /** + * Returns {@code true} if at least one of the provided {@code intents} can be forwarded + * from {@code source} (user id) to {@code target} (user id). + */ + public boolean hasCrossProfileIntents( + List<Intent> intents, @UserIdInt int source, @UserIdInt int target) { + return intents.stream().anyMatch(intent -> + null != IntentForwarderActivity.canForward(intent, source, target, + mPackageManager, mContentResolver)); + } +} + diff --git a/java/src/com/android/intentresolver/emptystate/EmptyState.java b/java/src/com/android/intentresolver/emptystate/EmptyState.java new file mode 100644 index 00000000..cde99fe1 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/EmptyState.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.annotation.Nullable; + +/** + * Model for the "empty state"/"blocker" UI to display instead of a profile tab's normal contents. + */ +public interface EmptyState { + /** + * Get the title to show on the empty state. + */ + @Nullable + default String getTitle() { + return null; + } + + /** + * Get the subtitle string to show underneath the title on the empty state. + */ + @Nullable + default String getSubtitle() { + return null; + } + + /** + * Get the handler for an optional button associated with this empty state. If the result is + * non-null, the empty-state UI will be built with a button that dispatches this handler. + */ + @Nullable + default ClickListener getButtonClickListener() { + return null; + } + + /** + * Get whether to show the default UI for the empty state. If true, the UI will show the default + * blocker text ('No apps can perform this action') and style; title and subtitle are ignored. + */ + default boolean useDefaultEmptyView() { + return false; + } + + /** + * Returns true if for this empty state we should skip rebuilding of the apps list + * for this tab. + */ + default boolean shouldSkipDataRebuild() { + return false; + } + + /** + * Called when empty state is shown, could be used e.g. to track analytics events. + */ + default void onEmptyStateShown() {} + + interface ClickListener { + void onClick(TabControl currentTab); + } + + interface TabControl { + void showSpinner(); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java new file mode 100644 index 00000000..c3261287 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate; + +import android.annotation.Nullable; + +import com.android.intentresolver.ResolverListAdapter; + +/** + * Returns an empty state to show for the current profile page (tab) if necessary. + * This could be used e.g. to show a blocker on a tab if device management policy doesn't + * allow to use it or there are no apps available. + */ +public interface EmptyStateProvider { + /** + * When a non-null empty state is returned the corresponding profile page will show + * this empty state + * @param resolverListAdapter the current adapter + */ + @Nullable + default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + return null; + } +} diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java new file mode 100644 index 00000000..d7ef8c75 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate; + +import android.view.View; +import android.view.ViewGroup; + +/** + * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by + * some empty-state status. + */ +public class EmptyStateUiHelper { + private final View mEmptyStateView; + + public EmptyStateUiHelper(ViewGroup rootView) { + mEmptyStateView = + rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); + } + + public void resetViewVisibilities() { + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) + .setVisibility(View.VISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) + .setVisibility(View.VISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) + .setVisibility(View.INVISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) + .setVisibility(View.GONE); + mEmptyStateView.requireViewById(com.android.internal.R.id.empty) + .setVisibility(View.GONE); + mEmptyStateView.setVisibility(View.VISIBLE); + } + + public void showSpinner() { + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) + .setVisibility(View.INVISIBLE); + // TODO: subtitle? + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) + .setVisibility(View.INVISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) + .setVisibility(View.VISIBLE); + mEmptyStateView.requireViewById(com.android.internal.R.id.empty) + .setVisibility(View.GONE); + } + + public void hide() { + mEmptyStateView.setVisibility(View.GONE); + } +} + diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java index a7b50f38..2653c560 100644 --- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -14,13 +14,11 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.emptystate; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; @@ -28,8 +26,11 @@ import android.content.pm.ResolveInfo; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ResolverListAdapter; import com.android.internal.R; import java.util.List; @@ -51,9 +52,12 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { @NonNull private final UserHandle mTabOwnerUserHandleForLaunch; - public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, - UserHandle personalProfileUserHandle, String metricsCategory, - UserHandle tabOwnerUserHandleForLaunch) { + public NoAppsAvailableEmptyStateProvider( + @NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @Nullable UserHandle personalProfileUserHandle, + @NonNull String metricsCategory, + @NonNull UserHandle tabOwnerUserHandleForLaunch) { mContext = context; mWorkProfileUserHandle = workProfileUserHandle; mPersonalProfileUserHandle = personalProfileUserHandle; @@ -76,12 +80,12 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { title = mContext.getSystemService( DevicePolicyManager.class).getResources().getString( RESOLVER_NO_PERSONAL_APPS, - () -> mContext.getString(R.string.resolver_no_personal_apps_available)); + () -> mContext.getString(R.string.resolver_no_personal_apps_available)); } else { title = mContext.getSystemService( DevicePolicyManager.class).getResources().getString( RESOLVER_NO_WORK_APPS, - () -> mContext.getString(R.string.resolver_no_work_apps_available)); + () -> mContext.getString(R.string.resolver_no_work_apps_available)); } return new NoAppsAvailableEmptyState( @@ -128,8 +132,9 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { private boolean mIsPersonalProfile; - public NoAppsAvailableEmptyState(String title, String metricsCategory, - boolean isPersonalProfile) { + public NoAppsAvailableEmptyState(@NonNull String title, + @NonNull String metricsCategory, + boolean isPersonalProfile) { mTitle = title; mMetricsCategory = metricsCategory; mIsPersonalProfile = isPersonalProfile; diff --git a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java index 6f72bb00..ce7bd8d9 100644 --- a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java @@ -14,19 +14,18 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.emptystate; -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.annotation.StringRes; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import com.android.intentresolver.ResolverListAdapter; /** * Empty state provider that does not allow cross profile sharing, it will return a blocker @@ -92,10 +91,14 @@ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { @NonNull private final String mEventCategory; - public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId, - @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId, + public DevicePolicyBlockerEmptyState( + @NonNull Context context, + String devicePolicyStringTitleId, + @StringRes int defaultTitleResource, + String devicePolicyStringSubtitleId, @StringRes int defaultSubtitleResource, - int devicePolicyEventId, String devicePolicyEventCategory) { + int devicePolicyEventId, + @NonNull String devicePolicyEventCategory) { mContext = context; mDevicePolicyStringTitleId = devicePolicyStringTitleId; mDefaultTitleResource = defaultTitleResource; diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java index 2f3dfbd5..612828e0 100644 --- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -14,21 +14,23 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.emptystate; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.WorkProfileAvailabilityManager; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when @@ -65,7 +67,7 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { final String title = mContext.getSystemService(DevicePolicyManager.class) .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, - () -> mContext.getString(R.string.resolver_turn_on_work_apps)); + () -> mContext.getString(R.string.resolver_turn_on_work_apps)); return new WorkProfileOffEmptyState(title, (tab) -> { tab.showSpinner(); diff --git a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt deleted file mode 100644 index d1494fe7..00000000 --- a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import android.provider.DeviceConfig -import com.android.systemui.flags.ParcelableFlag - -internal class DeviceConfigProxy { - fun isEnabled(flag: ParcelableFlag<Boolean>): Boolean? { - return runCatching { - val hasProperty = DeviceConfig.getProperty(flag.namespace, flag.name) != null - if (hasProperty) { - DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default) - } else { - null - } - }.getOrDefault(null) - } -} diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt deleted file mode 100644 index 2c20d341..00000000 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag - -// Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to -// make the flags available in the flag flipper app (see go/sysui-flags). -// All flags added should be included in UnbundledChooserActivityTest.ALL_FLAGS. -object Flags { - private fun releasedFlag(name: String) = ReleasedFlag(name, "systemui") - - private fun unreleasedFlag(name: String, teamfood: Boolean = false) = - UnreleasedFlag(name, "systemui", teamfood) -} diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index fadea934..51d4e677 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -32,9 +32,12 @@ import android.view.animation.DecelerateInterpolator; import android.widget.Space; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.FeatureFlags; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.internal.annotations.VisibleForTesting; @@ -107,6 +110,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. private final boolean mShouldShowContentPreview; private final int mChooserWidthPixels; private final int mChooserRowTextOptionTranslatePixelSize; + private final FeatureFlags mFeatureFlags; + @Nullable + private RecyclerView mRecyclerView; private int mChooserTargetWidth = 0; @@ -119,7 +125,8 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. ChooserActivityDelegate chooserActivityDelegate, ChooserListAdapter wrappedAdapter, boolean shouldShowContentPreview, - int maxTargetsPerRow) { + int maxTargetsPerRow, + FeatureFlags featureFlags) { super(); mChooserActivityDelegate = chooserActivityDelegate; @@ -133,6 +140,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. mChooserWidthPixels = context.getResources().getDimensionPixelSize(R.dimen.chooser_width); mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize( R.dimen.chooser_row_text_option_translate); + mFeatureFlags = featureFlags; wrappedAdapter.registerDataSetObserver(new DataSetObserver() { @Override @@ -149,6 +157,18 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. }); } + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + if (mFeatureFlags.scrollablePreview()) { + mRecyclerView = recyclerView; + } + } + + @Override + public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { + mRecyclerView = null; + } + public void setFooterHeight(int height) { mFooterHeight = height; } @@ -198,7 +218,8 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. public int getSystemRowCount() { // For the tabbed case we show the sticky content preview above the tabs, // please refer to shouldShowStickyContentPreview - if (mChooserActivityDelegate.shouldShowTabs()) { + if (mChooserActivityDelegate.shouldShowTabs() + || mFeatureFlags.scrollablePreview()) { return 0; } @@ -267,8 +288,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. + getFooterRowCount(); } + @NonNull @Override - public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { switch (viewType) { case VIEW_TYPE_CONTENT_PREVIEW: return new ItemViewHolder( @@ -304,7 +326,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. return new FooterViewHolder(sp, viewType); default: // Since we catch all possible viewTypes above, no chance this is being called. - return null; + throw new IllegalStateException("unmatched view type"); } } @@ -318,6 +340,15 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. mAzLabelVisibility = isVisible; int azRowPos = getAzLabelRowPosition(); if (azRowPos >= 0) { + if (mRecyclerView != null) { + for (int i = 0, size = mRecyclerView.getChildCount(); i < size; i++) { + View child = mRecyclerView.getChildAt(i); + if (mRecyclerView.getChildAdapterPosition(child) == azRowPos) { + child.setVisibility(isVisible ? View.VISIBLE : View.GONE); + } + } + return; + } notifyItemChanged(azRowPos); } } diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index 0e4d0209..054fbe71 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -18,7 +18,6 @@ package com.android.intentresolver.icons import android.app.ActivityManager import android.content.Context -import android.content.pm.ResolveInfo import android.graphics.drawable.Drawable import android.os.AsyncTask import android.os.UserHandle @@ -95,7 +94,7 @@ class DefaultTargetDataLoader( .executeOnExecutor(executor) } - override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<Array<CharSequence?>>) { + override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) { val taskId = nextTaskId.getAndIncrement() LoadLabelTask(context, info, isAudioCaptureDevice, presentationFactory) { result -> removeTask(taskId) @@ -105,8 +104,14 @@ class DefaultTargetDataLoader( .executeOnExecutor(executor) } - override fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter = - presentationFactory.makePresentationGetter(info) + override fun getOrLoadLabel(info: DisplayResolveInfo) { + if (!info.hasDisplayLabel()) { + val result = + LoadLabelTask.loadLabel(context, info, isAudioCaptureDevice, presentationFactory) + info.displayLabel = result.label + info.extendedInfo = result.subLabel + } + } private fun addTask(id: Int, task: AsyncTask<*, *, *>) { synchronized(activeTasks) { activeTasks.put(id, task) } diff --git a/java/src/com/android/intentresolver/icons/LabelInfo.kt b/java/src/com/android/intentresolver/icons/LabelInfo.kt new file mode 100644 index 00000000..a9c4cd77 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/LabelInfo.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.icons + +class LabelInfo(val label: CharSequence?, val subLabel: CharSequence?) diff --git a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java index 6aee69b5..0f135d63 100644 --- a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java +++ b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java @@ -16,7 +16,6 @@ package com.android.intentresolver.icons; -import android.annotation.Nullable; import android.content.ComponentName; import android.content.Context; import android.content.pm.ActivityInfo; @@ -30,6 +29,7 @@ import android.graphics.drawable.Icon; import android.os.Trace; import android.util.Log; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.android.intentresolver.SimpleIconFactory; diff --git a/java/src/com/android/intentresolver/icons/LoadLabelTask.java b/java/src/com/android/intentresolver/icons/LoadLabelTask.java index a0867b8e..6d443f78 100644 --- a/java/src/com/android/intentresolver/icons/LoadLabelTask.java +++ b/java/src/com/android/intentresolver/icons/LoadLabelTask.java @@ -28,16 +28,16 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import java.util.function.Consumer; -class LoadLabelTask extends AsyncTask<Void, Void, CharSequence[]> { +class LoadLabelTask extends AsyncTask<Void, Void, LabelInfo> { private final Context mContext; private final DisplayResolveInfo mDisplayResolveInfo; private final boolean mIsAudioCaptureDevice; protected final TargetPresentationGetter.Factory mPresentationFactory; - private final Consumer<CharSequence[]> mCallback; + private final Consumer<LabelInfo> mCallback; LoadLabelTask(Context context, DisplayResolveInfo dri, boolean isAudioCaptureDevice, TargetPresentationGetter.Factory presentationFactory, - Consumer<CharSequence[]> callback) { + Consumer<LabelInfo> callback) { mContext = context; mDisplayResolveInfo = dri; mIsAudioCaptureDevice = isAudioCaptureDevice; @@ -46,49 +46,52 @@ class LoadLabelTask extends AsyncTask<Void, Void, CharSequence[]> { } @Override - protected CharSequence[] doInBackground(Void... voids) { + protected LabelInfo doInBackground(Void... voids) { try { Trace.beginSection("app-label"); - return loadLabel(); + return loadLabel( + mContext, mDisplayResolveInfo, mIsAudioCaptureDevice, mPresentationFactory); } finally { Trace.endSection(); } } - private CharSequence[] loadLabel() { - TargetPresentationGetter pg = mPresentationFactory.makePresentationGetter( - mDisplayResolveInfo.getResolveInfo()); + static LabelInfo loadLabel( + Context context, + DisplayResolveInfo displayResolveInfo, + boolean isAudioCaptureDevice, + TargetPresentationGetter.Factory presentationFactory) { + TargetPresentationGetter pg = presentationFactory.makePresentationGetter( + displayResolveInfo.getResolveInfo()); - if (mIsAudioCaptureDevice) { + if (isAudioCaptureDevice) { // This is an audio capture device, so check record permissions - ActivityInfo activityInfo = mDisplayResolveInfo.getResolveInfo().activityInfo; + ActivityInfo activityInfo = displayResolveInfo.getResolveInfo().activityInfo; String packageName = activityInfo.packageName; int uid = activityInfo.applicationInfo.uid; boolean hasRecordPermission = PermissionChecker.checkPermissionForPreflight( - mContext, + context, android.Manifest.permission.RECORD_AUDIO, -1, uid, packageName) == android.content.pm.PackageManager.PERMISSION_GRANTED; if (!hasRecordPermission) { // Doesn't have record permission, so warn the user - return new CharSequence[]{ + return new LabelInfo( pg.getLabel(), - mContext.getString(R.string.usb_device_resolve_prompt_warn) - }; + context.getString(R.string.usb_device_resolve_prompt_warn)); } } - return new CharSequence[]{ + return new LabelInfo( pg.getLabel(), - pg.getSubLabel() - }; + pg.getSubLabel()); } @Override - protected void onPostExecute(CharSequence[] result) { + protected void onPostExecute(LabelInfo result) { mCallback.accept(result); } } diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt index 50f731f8..07c62177 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -16,10 +16,8 @@ package com.android.intentresolver.icons -import android.content.pm.ResolveInfo import android.graphics.drawable.Drawable import android.os.UserHandle -import com.android.intentresolver.TargetPresentationGetter import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo import java.util.function.Consumer @@ -41,10 +39,8 @@ abstract class TargetDataLoader { ) /** Load target label */ - abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer<Array<CharSequence?>>) + abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) - /** Create a presentation getter to be used with a [DisplayResolveInfo] */ - // TODO: get rid of DisplayResolveInfo's dependency on the presentation getter and remove this - // method. - abstract fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter + /** Loads DisplayResolveInfo's display label synchronously, if needed */ + abstract fun getOrLoadLabel(info: DisplayResolveInfo) } diff --git a/java/src/com/android/intentresolver/inject/ActivityModule.kt b/java/src/com/android/intentresolver/inject/ActivityModule.kt new file mode 100644 index 00000000..21bfe4c6 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ActivityModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.app.Activity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import kotlinx.coroutines.CoroutineScope + +@Module +@InstallIn(ActivityComponent::class) +object ActivityModule { + + @Provides + @ActivityOwned + fun lifecycle(activity: Activity): Lifecycle { + check(activity is LifecycleOwner) { "activity must implement LifecycleOwner" } + return activity.lifecycle + } + + @Provides + @ActivityOwned + fun activityScope(activity: Activity): CoroutineScope { + check(activity is LifecycleOwner) { "activity must implement LifecycleOwner" } + return activity.lifecycleScope + } +} diff --git a/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt new file mode 100644 index 00000000..e0f8e88b --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@InstallIn(SingletonComponent::class) +object ConcurrencyModule { + + @Provides @Main fun mainDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate + + /** Injectable alternative to [MainScope()][kotlinx.coroutines.MainScope] */ + @Provides + @Singleton + @Main + fun mainCoroutineScope(@Main mainDispatcher: CoroutineDispatcher) = + CoroutineScope(SupervisorJob() + mainDispatcher) + + @Provides @Background fun backgroundDispatcher(): CoroutineDispatcher = Dispatchers.IO +} diff --git a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt new file mode 100644 index 00000000..05cf2104 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt @@ -0,0 +1,15 @@ +package com.android.intentresolver.inject + +import com.android.intentresolver.FeatureFlags +import com.android.intentresolver.FeatureFlagsImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object FeatureFlagsModule { + + @Provides fun featureFlags(): FeatureFlags = FeatureFlagsImpl() +} diff --git a/java/src/com/android/intentresolver/inject/FrameworkModule.kt b/java/src/com/android/intentresolver/inject/FrameworkModule.kt new file mode 100644 index 00000000..2f6cc6a0 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/FrameworkModule.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.app.ActivityManager +import android.app.admin.DevicePolicyManager +import android.content.ClipboardManager +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.ShortcutManager +import android.os.UserManager +import android.view.WindowManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +private fun <T> Context.requireSystemService(serviceClass: Class<T>): T { + return checkNotNull(getSystemService(serviceClass)) +} + +@Module +@InstallIn(SingletonComponent::class) +object FrameworkModule { + + @Provides + fun contentResolver(@ApplicationContext ctx: Context) = + requireNotNull(ctx.contentResolver) { "ContentResolver is expected but missing" } + + @Provides + fun activityManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(ActivityManager::class.java) + + @Provides + fun clipboardManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(ClipboardManager::class.java) + + @Provides + fun devicePolicyManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(DevicePolicyManager::class.java) + + @Provides + fun launcherApps(@ApplicationContext ctx: Context) = + ctx.requireSystemService(LauncherApps::class.java) + + @Provides + fun packageManager(@ApplicationContext ctx: Context) = + requireNotNull(ctx.packageManager) { "PackageManager is expected but missing" } + + @Provides + fun shortcutManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(ShortcutManager::class.java) + + @Provides + fun userManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(UserManager::class.java) + + @Provides + fun windowManager(@ApplicationContext ctx: Context) = + ctx.requireSystemService(WindowManager::class.java) +} diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt new file mode 100644 index 00000000..157e8f76 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import javax.inject.Qualifier + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ActivityOwned + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationOwned + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationUser + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ProfileParent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Default + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Main diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt new file mode 100644 index 00000000..e517800d --- /dev/null +++ b/java/src/com/android/intentresolver/inject/SingletonModule.kt @@ -0,0 +1,22 @@ +package com.android.intentresolver.inject + +import android.content.Context +import com.android.intentresolver.logging.EventLogImpl +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object SingletonModule { + @Provides @Singleton fun instanceIdSequence() = EventLogImpl.newIdSequence() + + @Provides + @Reusable + @ApplicationOwned + fun resources(@ApplicationContext context: Context) = context.resources +} diff --git a/java/src/com/android/intentresolver/logging/EventLog.kt b/java/src/com/android/intentresolver/logging/EventLog.kt new file mode 100644 index 00000000..476bd4bf --- /dev/null +++ b/java/src/com/android/intentresolver/logging/EventLog.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.logging + +import android.net.Uri +import android.util.HashedStringCache + +/** Logs notable events during ShareSheet usage. */ +interface EventLog { + + companion object { + const val SELECTION_TYPE_SERVICE = 1 + const val SELECTION_TYPE_APP = 2 + const val SELECTION_TYPE_STANDARD = 3 + const val SELECTION_TYPE_COPY = 4 + const val SELECTION_TYPE_NEARBY = 5 + const val SELECTION_TYPE_EDIT = 6 + const val SELECTION_TYPE_MODIFY_SHARE = 7 + const val SELECTION_TYPE_CUSTOM_ACTION = 8 + } + + fun logChooserActivityShown(isWorkProfile: Boolean, targetMimeType: String?, systemCost: Long) + + fun logShareStarted( + packageName: String?, + mimeType: String?, + appProvidedDirect: Int, + appProvidedApp: Int, + isWorkprofile: Boolean, + previewType: Int, + intent: String?, + customActionCount: Int, + modifyShareActionProvided: Boolean + ) + + fun logCustomActionSelected(positionPicked: Int) + fun logShareTargetSelected( + targetType: Int, + packageName: String?, + positionPicked: Int, + directTargetAlsoRanked: Int, + numCallerProvided: Int, + directTargetHashed: HashedStringCache.HashResult?, + isPinned: Boolean, + successfullySelected: Boolean, + selectionCost: Long + ) + + fun logDirectShareTargetReceived(category: Int, latency: Int) + fun logActionShareWithPreview(previewType: Int) + fun logActionSelected(targetType: Int) + fun logContentPreviewWarning(uri: Uri?) + fun logSharesheetTriggered() + fun logSharesheetAppLoadComplete() + fun logSharesheetDirectLoadComplete() + fun logSharesheetDirectLoadTimeout() + fun logSharesheetProfileChanged() + fun logSharesheetExpansionChanged(isCollapsed: Boolean) + fun logSharesheetAppShareRankingTimeout() + fun logSharesheetEmptyDirectShareRow() +} diff --git a/java/src/com/android/intentresolver/logging/EventLog.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java index b30e825b..84029e76 100644 --- a/java/src/com/android/intentresolver/logging/EventLog.java +++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java @@ -16,7 +16,6 @@ package com.android.intentresolver.logging; -import android.annotation.Nullable; import android.content.Intent; import android.metrics.LogMaker; import android.net.Uri; @@ -24,6 +23,8 @@ import android.provider.MediaStore; import android.util.HashedStringCache; import android.util.Log; +import androidx.annotation.Nullable; + import com.android.intentresolver.ChooserActivity; import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.annotations.VisibleForTesting; @@ -32,84 +33,42 @@ import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; -import com.android.internal.logging.UiEventLoggerImpl; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; +import javax.inject.Inject; + /** * Helper for writing Sharesheet atoms to statsd log. - * @hide */ -public class EventLog { +public class EventLogImpl implements EventLog { private static final String TAG = "ChooserActivity"; private static final boolean DEBUG = true; - public static final int SELECTION_TYPE_SERVICE = 1; - public static final int SELECTION_TYPE_APP = 2; - public static final int SELECTION_TYPE_STANDARD = 3; - public static final int SELECTION_TYPE_COPY = 4; - public static final int SELECTION_TYPE_NEARBY = 5; - public static final int SELECTION_TYPE_EDIT = 6; - public static final int SELECTION_TYPE_MODIFY_SHARE = 7; - public static final int SELECTION_TYPE_CUSTOM_ACTION = 8; - - /** - * This shim is provided only for testing. In production, clients will only ever use a - * {@link DefaultFrameworkStatsLogger}. - */ - @VisibleForTesting - interface FrameworkStatsLogger { - /** Overload to use for logging {@code FrameworkStatsLog.SHARESHEET_STARTED}. */ - void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - String mimeType, - int numAppProvidedDirectTargets, - int numAppProvidedAppTargets, - boolean isWorkProfile, - int previewType, - int intentType, - int numCustomActions, - boolean modifyShareActionProvided); - - /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */ - void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - int positionPicked, - boolean isPinned); - } - private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); - // A small per-notification ID, used for statsd logging. - // TODO: consider precomputing and storing as final. - private static InstanceIdSequence sInstanceIdSequence; - private InstanceId mInstanceId; + private final InstanceId mInstanceId; private final UiEventLogger mUiEventLogger; private final FrameworkStatsLogger mFrameworkStatsLogger; private final MetricsLogger mMetricsLogger; - public EventLog() { - this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger()); + public static InstanceIdSequence newIdSequence() { + return new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); } - @VisibleForTesting - EventLog( - UiEventLogger uiEventLogger, - FrameworkStatsLogger frameworkLogger, - MetricsLogger metricsLogger) { + @Inject + public EventLogImpl(UiEventLogger uiEventLogger, FrameworkStatsLogger frameworkLogger, + MetricsLogger metricsLogger, InstanceId instanceId) { mUiEventLogger = uiEventLogger; mFrameworkStatsLogger = frameworkLogger; mMetricsLogger = metricsLogger; + mInstanceId = instanceId; } + /** Records metrics for the start time of the {@link ChooserActivity}. */ + @Override public void logChooserActivityShown( boolean isWorkProfile, String targetMimeType, long systemCost) { mMetricsLogger.write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) @@ -120,6 +79,7 @@ public class EventLog { } /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ + @Override public void logShareStarted( String packageName, String mimeType, @@ -133,7 +93,7 @@ public class EventLog { mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED, /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* mime_type = 4 */ mimeType, /* num_app_provided_direct_targets = 5 */ appProvidedDirect, /* num_app_provided_app_targets = 6 */ appProvidedApp, @@ -149,12 +109,13 @@ public class EventLog { * * @param positionPicked index of the custom action within the list of custom actions. */ + @Override public void logCustomActionSelected(int positionPicked) { mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, /* event_id = 1 */ SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId(), /* package_name = 2 */ null, - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* position_picked = 4 */ positionPicked, /* is_pinned = 5 */ false); } @@ -164,6 +125,7 @@ public class EventLog { * TODO: document parameters and/or consider breaking up by targetType so we don't have to * support an overly-generic signature. */ + @Override public void logShareTargetSelected( int targetType, String packageName, @@ -177,7 +139,7 @@ public class EventLog { mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* position_picked = 4 */ positionPicked, /* is_pinned = 5 */ isPinned); @@ -209,6 +171,7 @@ public class EventLog { } /** Log when direct share targets were received. */ + @Override public void logDirectShareTargetReceived(int category, int latency) { mMetricsLogger.write(new LogMaker(category).setSubtype(latency)); } @@ -217,12 +180,14 @@ public class EventLog { * Log when we display a preview UI of the specified {@code previewType} as part of our * Sharesheet session. */ + @Override public void logActionShareWithPreview(int previewType) { mMetricsLogger.write( new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW).setSubtype(previewType)); } /** Log when the user selects an action button with the specified {@code targetType}. */ + @Override public void logActionSelected(int targetType) { if (targetType == SELECTION_TYPE_COPY) { LogMaker targetLogMaker = new LogMaker( @@ -232,12 +197,13 @@ public class EventLog { mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), /* package_name = 2 */ "", - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* position_picked = 4 */ -1, /* is_pinned = 5 */ false); } /** Log a warning that we couldn't display the content preview from the supplied {@code uri}. */ + @Override public void logContentPreviewWarning(Uri uri) { // The ContentResolver already logs the exception. Log something more informative. Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " @@ -248,55 +214,63 @@ public class EventLog { } /** Logs a UiEventReported event for the system sharesheet being triggered by the user. */ + @Override public void logSharesheetTriggered() { - log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, mInstanceId); } /** Logs a UiEventReported event for the system sharesheet completing loading app targets. */ + @Override public void logSharesheetAppLoadComplete() { - log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet completing loading service targets. */ + @Override public void logSharesheetDirectLoadComplete() { - log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet timing out loading service targets. */ + @Override public void logSharesheetDirectLoadTimeout() { - log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet switching * between work and main profile. */ + @Override public void logSharesheetProfileChanged() { - log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, mInstanceId); } /** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */ + @Override public void logSharesheetExpansionChanged(boolean isCollapsed) { log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED : - SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId()); + SharesheetStandardEvent.SHARESHEET_EXPANDED, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet app share ranking timing out. */ + @Override public void logSharesheetAppShareRankingTimeout() { - log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet when direct share row is empty. */ + @Override public void logSharesheetEmptyDirectShareRow() { - log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, mInstanceId); } /** @@ -313,19 +287,6 @@ public class EventLog { } /** - * @return A unique {@link InstanceId} to join across events recorded by this logger instance. - */ - private InstanceId getInstanceId() { - if (mInstanceId == null) { - if (sInstanceIdSequence == null) { - sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); - } - mInstanceId = sInstanceIdSequence.newInstanceId(); - } - return mInstanceId; - } - - /** * The UiEvent enums that this class can log. */ enum SharesheetStartedEvent implements UiEventLogger.UiEventEnum { @@ -488,52 +449,4 @@ public class EventLog { return 0; } } - - private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger { - @Override - public void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - String mimeType, - int numAppProvidedDirectTargets, - int numAppProvidedAppTargets, - boolean isWorkProfile, - int previewType, - int intentType, - int numCustomActions, - boolean modifyShareActionProvided) { - FrameworkStatsLog.write( - frameworkEventId, - /* event_id = 1 */ appEventId, - /* package_name = 2 */ packageName, - /* instance_id = 3 */ instanceId, - /* mime_type = 4 */ mimeType, - /* num_app_provided_direct_targets */ numAppProvidedDirectTargets, - /* num_app_provided_app_targets */ numAppProvidedAppTargets, - /* is_workprofile */ isWorkProfile, - /* previewType = 8 */ previewType, - /* intentType = 9 */ intentType, - /* num_provided_custom_actions = 10 */ numCustomActions, - /* modify_share_action_provided = 11 */ modifyShareActionProvided); - } - - @Override - public void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - int positionPicked, - boolean isPinned) { - FrameworkStatsLog.write( - frameworkEventId, - /* event_id = 1 */ appEventId, - /* package_name = 2 */ packageName, - /* instance_id = 3 */ instanceId, - /* position_picked = 4 */ positionPicked, - /* is_pinned = 5 */ isPinned); - } - } } diff --git a/java/src/com/android/intentresolver/logging/EventLogModule.kt b/java/src/com/android/intentresolver/logging/EventLogModule.kt new file mode 100644 index 00000000..eba8ecc8 --- /dev/null +++ b/java/src/com/android/intentresolver/logging/EventLogModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.logging + +import com.android.internal.logging.InstanceId +import com.android.internal.logging.InstanceIdSequence +import com.android.internal.logging.MetricsLogger +import com.android.internal.logging.UiEventLogger +import com.android.internal.logging.UiEventLoggerImpl +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +interface EventLogModule { + + @Binds @ActivityScoped fun eventLog(value: EventLogImpl): EventLog + + companion object { + @Provides + fun instanceId(sequence: InstanceIdSequence): InstanceId = sequence.newInstanceId() + + @Provides fun uiEventLogger(): UiEventLogger = UiEventLoggerImpl() + + @Provides fun frameworkLogger(): FrameworkStatsLogger = object : FrameworkStatsLogger {} + + @Provides fun metricsLogger(): MetricsLogger = MetricsLogger() + } +} diff --git a/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt new file mode 100644 index 00000000..6508d305 --- /dev/null +++ b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.logging + +import com.android.internal.util.FrameworkStatsLog + +/** A documenting annotation for FrameworkStatsLog methods and their associated UiEvents. */ +internal annotation class ForUiEvent(vararg val uiEventId: Int) + +/** Isolates the specific method signatures to use for each of the logged UiEvents. */ +interface FrameworkStatsLogger { + + @ForUiEvent(FrameworkStatsLog.SHARESHEET_STARTED) + fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + mimeType: String?, + numAppProvidedDirectTargets: Int, + numAppProvidedAppTargets: Int, + isWorkProfile: Boolean, + previewType: Int, + intentType: Int, + numCustomActions: Int, + modifyShareActionProvided: Boolean, + ) { + FrameworkStatsLog.write( + frameworkEventId, /* event_id = 1 */ + appEventId, /* package_name = 2 */ + packageName, /* instance_id = 3 */ + instanceId, /* mime_type = 4 */ + mimeType, /* num_app_provided_direct_targets */ + numAppProvidedDirectTargets, /* num_app_provided_app_targets */ + numAppProvidedAppTargets, /* is_workprofile */ + isWorkProfile, /* previewType = 8 */ + previewType, /* intentType = 9 */ + intentType, /* num_provided_custom_actions = 10 */ + numCustomActions, /* modify_share_action_provided = 11 */ + modifyShareActionProvided + ) + } + + @ForUiEvent(FrameworkStatsLog.RANKING_SELECTED) + fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + positionPicked: Int, + isPinned: Boolean, + ) { + FrameworkStatsLog.write( + frameworkEventId, /* event_id = 1 */ + appEventId, /* package_name = 2 */ + packageName, /* instance_id = 3 */ + instanceId, /* position_picked = 4 */ + positionPicked, /* is_pinned = 5 */ + isPinned + ) + } +} diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index ff2d6a0f..724fa849 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -16,7 +16,6 @@ package com.android.intentresolver.model; -import android.annotation.Nullable; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.Context; @@ -30,10 +29,13 @@ import android.os.Message; import android.os.UserHandle; import android.util.Log; -import com.android.intentresolver.logging.EventLog; +import androidx.annotation.Nullable; + import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.logging.EventLog; import java.text.Collator; import java.util.ArrayList; @@ -75,6 +77,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC private EventLog mEventLog; protected final Handler mHandler = new Handler(Looper.getMainLooper()) { + @Override public void handleMessage(Message msg) { switch (msg.what) { case RANKER_SERVICE_RESULT: @@ -229,7 +232,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC * {@link ResolvedComponentInfo#getResolveInfoAt(int)} from the parameters of {@link * #compare(ResolvedComponentInfo, ResolvedComponentInfo)} */ - abstract int compare(ResolveInfo lhs, ResolveInfo rhs); + public abstract int compare(ResolveInfo lhs, ResolveInfo rhs); /** * Computes features for each target. This will be called before calls to {@link @@ -245,7 +248,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC } /** Implementation of compute called after {@link #beforeCompute()}. */ - abstract void doCompute(List<ResolvedComponentInfo> targets); + public abstract void doCompute(List<ResolvedComponentInfo> targets); /** * Returns the score that was calculated for the corresponding {@link ResolvedComponentInfo} @@ -254,12 +257,12 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC public abstract float getScore(TargetInfo targetInfo); /** Handles result message sent to mHandler. */ - abstract void handleResultMessage(Message message); + public abstract void handleResultMessage(Message message); /** * Reports to UsageStats what was chosen. */ - public final void updateChooserCounts(String packageName, UserHandle user, String action) { + public void updateChooserCounts(String packageName, UserHandle user, String action) { if (mUsmMap.containsKey(user)) { mUsmMap.get(user).reportChooserSelection( packageName, diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index 621ae306..0651d26c 100644 --- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -18,7 +18,6 @@ package com.android.intentresolver.model; import static android.app.prediction.AppTargetEvent.ACTION_LAUNCH; -import android.annotation.Nullable; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; @@ -31,9 +30,12 @@ import android.os.Message; import android.os.UserHandle; import android.util.Log; -import com.android.intentresolver.logging.EventLog; +import androidx.annotation.Nullable; + import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.shortcuts.ScopedAppTargetListCallback; import com.google.android.collect.Lists; @@ -85,12 +87,12 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp } @Override - int compare(ResolveInfo lhs, ResolveInfo rhs) { + public int compare(ResolveInfo lhs, ResolveInfo rhs) { return mComparatorModel.getComparator().compare(lhs, rhs); } @Override - void doCompute(List<ResolvedComponentInfo> targets) { + public void doCompute(List<ResolvedComponentInfo> targets) { if (targets.isEmpty()) { mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT); return; @@ -105,33 +107,44 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp .setClassName(target.name.getClassName()) .build()); } - mAppPredictor.sortTargets(appTargets, Executors.newSingleThreadExecutor(), - sortedAppTargets -> { - if (sortedAppTargets.isEmpty()) { - Log.i(TAG, "AppPredictionService disabled. Using resolver."); - // APS for chooser is disabled. Fallback to resolver. - mResolverRankerService = - new ResolverRankerServiceResolverComparator( - mContext, - mIntent, - mReferrerPackage, - () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), - getEventLog(), - mUser, - mPromoteToFirst); - mComparatorModel = buildUpdatedModel(); - mResolverRankerService.compute(targets); - } else { - Log.i(TAG, "AppPredictionService response received"); - // Skip sending to Handler which takes extra time to dispatch messages. - handleResult(sortedAppTargets); - } - } + mAppPredictor.sortTargets( + appTargets, + Executors.newSingleThreadExecutor(), + new ScopedAppTargetListCallback( + mContext, + sortedAppTargets -> { + onAppTargetsSorted(targets, sortedAppTargets); + return kotlin.Unit.INSTANCE; + }).toConsumer() ); } + private void onAppTargetsSorted( + List<ResolvedComponentInfo> targets, List<AppTarget> sortedAppTargets) { + if (sortedAppTargets.isEmpty()) { + Log.i(TAG, "AppPredictionService disabled. Using resolver."); + // APS for chooser is disabled. Fallback to resolver. + mResolverRankerService = + new ResolverRankerServiceResolverComparator( + mContext, + mIntent, + mReferrerPackage, + () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), + getEventLog(), + mUser, + mPromoteToFirst); + mComparatorModel = buildUpdatedModel(); + mResolverRankerService.compute(targets); + } else { + Log.i(TAG, "AppPredictionService response received"); + // Skip sending to Handler which takes extra time to dispatch + // messages. + handleResult(sortedAppTargets); + } + } + @Override - void handleResultMessage(Message msg) { + public void handleResultMessage(Message msg) { // Null value is okay if we have defaulted to the ResolverRankerService. if (msg.what == RANKER_SERVICE_RESULT && msg.obj != null) { final List<AppTarget> sortedAppTargets = (List<AppTarget>) msg.obj; diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index 7d473660..f3804154 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -17,7 +17,6 @@ package com.android.intentresolver.model; -import android.annotation.Nullable; import android.app.usage.UsageStats; import android.content.ComponentName; import android.content.Context; @@ -39,9 +38,11 @@ import android.service.resolver.ResolverRankerService; import android.service.resolver.ResolverTarget; import android.util.Log; -import com.android.intentresolver.logging.EventLog; +import androidx.annotation.Nullable; + import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.logging.EventLog; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -101,9 +102,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom * the userSpace provided by context. */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, - String referrerPackage, Runnable afterCompute, - EventLog eventLog, UserHandle targetUserSpace, - ComponentName promoteToFirst) { + String referrerPackage, Runnable afterCompute, + EventLog eventLog, UserHandle targetUserSpace, + ComponentName promoteToFirst) { this(launchedFromContext, intent, referrerPackage, afterCompute, eventLog, Lists.newArrayList(targetUserSpace), promoteToFirst); } @@ -117,9 +118,8 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom * different from the userSpace provided by context. */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, - String referrerPackage, Runnable afterCompute, - EventLog eventLog, List<UserHandle> targetUserSpaceList, - @Nullable ComponentName promoteToFirst) { + String referrerPackage, Runnable afterCompute, EventLog eventLog, + List<UserHandle> targetUserSpaceList, @Nullable ComponentName promoteToFirst) { super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst); mCollator = Collator.getInstance( launchedFromContext.getResources().getConfiguration().locale); diff --git a/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt b/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt new file mode 100644 index 00000000..9606a6a1 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.shortcuts + +import android.app.prediction.AppPredictor +import android.app.prediction.AppTarget +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.coroutineScope +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch + +/** + * A memory leak workaround for b/290971946. Drops the references to the actual [callback] when the + * [scope] is cancelled allowing it to be garbage-collected (and only leaking this instance). + */ +class ScopedAppTargetListCallback( + scope: CoroutineScope?, + callback: (List<AppTarget>) -> Unit, +) { + + @Volatile private var callbackRef: ((List<AppTarget>) -> Unit)? = callback + + constructor( + context: Context, + callback: (List<AppTarget>) -> Unit, + ) : this((context as? LifecycleOwner)?.lifecycle?.coroutineScope, callback) + + init { + scope?.launch { awaitCancellation() }?.invokeOnCompletion { callbackRef = null } + } + + private fun notifyCallback(result: List<AppTarget>) { + callbackRef?.invoke(result) + } + + fun toConsumer(): Consumer<MutableList<AppTarget>?> = + Consumer<MutableList<AppTarget>?> { notifyCallback(it ?: emptyList()) } + + fun toAppPredictorCallback(): AppPredictor.Callback = + AppPredictor.Callback { notifyCallback(it) } +} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index f05542e2..a8b59fb0 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -35,14 +35,13 @@ import androidx.annotation.MainThread import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.measurements.Tracer import com.android.intentresolver.measurements.runTracing import java.util.concurrent.Executor import java.util.function.Consumer import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.BufferOverflow @@ -50,6 +49,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** @@ -58,14 +58,14 @@ import kotlinx.coroutines.launch * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut * updates. The shortcut loading is triggered in the constructor or by the [reset] method, the * processing happens on the [dispatcher] and the result is delivered through the [callback] on the - * default [lifecycle]'s dispatcher, the main thread. + * default [scope]'s dispatcher, the main thread. */ @OpenForTesting open class ShortcutLoader @VisibleForTesting constructor( private val context: Context, - private val lifecycle: Lifecycle, + private val scope: CoroutineScope, private val appPredictor: AppPredictorProxy?, private val userHandle: UserHandle, private val isPersonalProfile: Boolean, @@ -75,7 +75,9 @@ constructor( ) { private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager - private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) } + private val appPredictorCallback = + ScopedAppTargetListCallback(scope) { onAppPredictorCallback(it) }.toAppPredictorCallback() + private val appTargetSource = MutableSharedFlow<Array<DisplayResolveInfo>?>( replay = 1, @@ -84,19 +86,19 @@ constructor( private val shortcutSource = MutableSharedFlow<ShortcutData?>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val isDestroyed - get() = !lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) + get() = !scope.isActive @MainThread constructor( context: Context, - lifecycle: Lifecycle, + scope: CoroutineScope, appPredictor: AppPredictor?, userHandle: UserHandle, targetIntentFilter: IntentFilter?, callback: Consumer<Result> ) : this( context, - lifecycle, + scope, appPredictor?.let { AppPredictorProxy(it) }, userHandle, userHandle == UserHandle.of(ActivityManager.getCurrentUser()), @@ -107,7 +109,7 @@ constructor( init { appPredictor?.registerPredictionUpdates(dispatcher.asExecutor(), appPredictorCallback) - lifecycle.coroutineScope + scope .launch { appTargetSource .combine(shortcutSource) { appTargets, shortcutData -> @@ -135,13 +137,13 @@ constructor( reset() } - /** Clear application targets (see [updateAppTargets] and initiate shrtcuts loading. */ + /** Clear application targets (see [updateAppTargets] and initiate shortcuts loading. */ @OpenForTesting open fun reset() { Log.d(TAG, "reset shortcut loader for user $userHandle") appTargetSource.tryEmit(null) shortcutSource.tryEmit(null) - lifecycle.coroutineScope.launch(dispatcher) { loadShortcuts() } + scope.launch(dispatcher) { loadShortcuts() } } /** diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java index a37d6558..31929948 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java @@ -16,8 +16,6 @@ package com.android.intentresolver.shortcuts; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.prediction.AppTarget; import android.content.Intent; import android.content.pm.ShortcutInfo; @@ -25,6 +23,9 @@ import android.content.pm.ShortcutManager; import android.os.Bundle; import android.service.chooser.ChooserTarget; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt new file mode 100644 index 00000000..c81bed09 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -0,0 +1,156 @@ +package com.android.intentresolver.v2 + +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK +import android.content.Intent +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.core.content.getSystemService +import com.android.intentresolver.AnnotatedUserHandles +import com.android.intentresolver.R +import com.android.intentresolver.WorkProfileAvailabilityManager +import com.android.intentresolver.icons.TargetDataLoader + +/** + * Logic for IntentResolver Activities. Anything that is not the same across activities (including + * test activities) should be in this interface. Expect there to be one implementation for each + * activity, including test activities, but all implementations should delegate to a + * CommonActivityLogic implementation. + */ +interface ActivityLogic : CommonActivityLogic { + /** The intent for the target. This will always come before additional targets, if any. */ + val targetIntent: Intent + /** Whether the intent is for home. */ + val resolvingHome: Boolean + /** Custom title to display. */ + val title: CharSequence? + /** Resource ID for the title to display when there is no custom title. */ + val defaultTitleResId: Int + /** Intents received to be processed. */ + val initialIntents: List<Intent>? + /** Whether or not this activity supports choosing a default handler for the intent. */ + val supportsAlwaysUseOption: Boolean + /** Fetches display info for processed candidates. */ + val targetDataLoader: TargetDataLoader + /** The theme to use. */ + val themeResId: Int + /** + * Message showing that intent is forwarded from managed profile to owner or other way around. + */ + val profileSwitchMessage: String? + /** The intents for potential actual targets. [targetIntent] must be first. */ + val payloadIntents: List<Intent> + + /** + * Called after Activity superclass creation, but before any other onCreate logic is performed. + */ + fun preInitialization() + + /** Sets [profileSwitchMessage] to null */ + fun clearProfileSwitchMessage() +} + +/** + * Logic that is common to all IntentResolver activities. Anything that is the same across + * activities (including test activities), should live here. + */ +interface CommonActivityLogic { + /** The tag to use when logging. */ + val tag: String + /** A reference to the activity owning, and used by, this logic. */ + val activity: ComponentActivity + /** The name of the referring package. */ + val referrerPackageName: String? + /** User manager system service. */ + val userManager: UserManager + /** Device policy manager system service. */ + val devicePolicyManager: DevicePolicyManager + /** Current [UserHandle]s retrievable by type. */ + val annotatedUserHandles: AnnotatedUserHandles? + /** Monitors for changes to work profile availability. */ + val workProfileAvailabilityManager: WorkProfileAvailabilityManager + + /** Returns display message indicating intent forwarding or null if not intent forwarding. */ + fun forwardMessageFor(intent: Intent): String? +} + +/** + * Concrete implementation of the [CommonActivityLogic] interface meant to be delegated to by + * [ActivityLogic] implementations. Test implementations of [ActivityLogic] may need to create their + * own [CommonActivityLogic] implementation. + */ +class CommonActivityLogicImpl( + override val tag: String, + activityProvider: () -> ComponentActivity, + onWorkProfileStatusUpdated: () -> Unit, +) : CommonActivityLogic { + + override val activity: ComponentActivity by lazy { activityProvider() } + + override val referrerPackageName: String? by lazy { + activity.referrer.let { + if (ANDROID_APP_URI_SCHEME == it?.scheme) { + it.host + } else { + null + } + } + } + + override val userManager: UserManager by lazy { activity.getSystemService()!! } + + override val devicePolicyManager: DevicePolicyManager by lazy { activity.getSystemService()!! } + + override val annotatedUserHandles: AnnotatedUserHandles? by lazy { + try { + AnnotatedUserHandles.forShareActivity(activity) + } catch (e: SecurityException) { + Log.e(tag, "Request from UID without necessary permissions", e) + null + } + } + + override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy { + WorkProfileAvailabilityManager( + userManager, + annotatedUserHandles?.workProfileUserHandle, + onWorkProfileStatusUpdated, + ) + } + + private val forwardToPersonalMessage: String? by lazy { + devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { + activity.getString(R.string.forward_intent_to_owner) + } + } + + private val forwardToWorkMessage: String? by lazy { + devicePolicyManager.resources.getString(FORWARD_INTENT_TO_WORK) { + activity.getString(R.string.forward_intent_to_work) + } + } + + override fun forwardMessageFor(intent: Intent): String? { + val contentUserHint = intent.contentUserHint + if ( + contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId() + ) { + val originUserInfo = userManager.getUserInfo(contentUserHint) + val originIsManaged = originUserInfo?.isManagedProfile ?: false + val targetIsManaged = userManager.isManagedProfile + return when { + originIsManaged && !targetIsManaged -> forwardToPersonalMessage + !originIsManaged && targetIsManaged -> forwardToWorkMessage + else -> null + } + } + return null + } + + companion object { + private const val ANDROID_APP_URI_SCHEME = "android-app" + } +} diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java new file mode 100644 index 00000000..db840387 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java @@ -0,0 +1,395 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import android.app.Activity; +import android.app.ActivityOptions; +import android.app.PendingIntent; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.service.chooser.ChooserAction; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.android.intentresolver.R; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.widget.ActionRow; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.function.Consumer; + +/** + * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application + * requirements of Sharesheet / {@link ChooserActivity}. + */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { + /** + * Delegate interface to launch activities when the actions are selected. + */ + public interface ActionActivityStarter { + /** + * Request an activity launch for the provided target. Implementations may choose to exit + * the current activity when the target is launched. + */ + void safelyStartActivityAsPersonalProfileUser(TargetInfo info); + + /** + * Request an activity launch for the provided target, optionally employing the specified + * shared element transition. Implementations may choose to exit the current activity when + * the target is launched. + */ + default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo info, View sharedElement, String sharedElementName) { + safelyStartActivityAsPersonalProfileUser(info); + } + } + + private static final String TAG = "ChooserActions"; + + private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + + // Boolean extra used to inform the editor that it may want to customize the editing experience + // for the sharesheet editing flow. + private static final String EDIT_SOURCE = "edit_source"; + private static final String EDIT_SOURCE_SHARESHEET = "sharesheet"; + + private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; + private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; + + private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; + + private final Context mContext; + + @Nullable + private final Runnable mCopyButtonRunnable; + private final Runnable mEditButtonRunnable; + private final ImmutableList<ChooserAction> mCustomActions; + private final @Nullable ChooserAction mModifyShareAction; + private final Consumer<Boolean> mExcludeSharedTextAction; + private final Consumer</* @Nullable */ Integer> mFinishCallback; + private final EventLog mLog; + + /** + * @param context + * @param imageEditor an explicit Activity to launch for editing images + * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" + * setting is updated. The argument is whether the shared text is to be excluded. + * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image + * View in the Sharesheet UI, if any, or null. + * @param activityStarter a delegate to launch activities when actions are selected. + * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was + * completed). + */ + public ChooserActionFactory( + Context context, + Intent targetIntent, + String referrerPackageName, + List<ChooserAction> chooserActions, + ChooserAction modifyShareAction, + Optional<ComponentName> imageEditor, + EventLog log, + Consumer<Boolean> onUpdateSharedTextIsExcluded, + Callable</* @Nullable */ View> firstVisibleImageQuery, + ActionActivityStarter activityStarter, + Consumer</* @Nullable */ Integer> finishCallback) { + this( + context, + makeCopyButtonRunnable( + context, + targetIntent, + referrerPackageName, + finishCallback, + log), + makeEditButtonRunnable( + getEditSharingTarget( + context, + targetIntent, + imageEditor), + firstVisibleImageQuery, + activityStarter, + log), + chooserActions, + modifyShareAction, + onUpdateSharedTextIsExcluded, + log, + finishCallback); + } + + @VisibleForTesting + ChooserActionFactory( + Context context, + @Nullable Runnable copyButtonRunnable, + Runnable editButtonRunnable, + List<ChooserAction> customActions, + @Nullable ChooserAction modifyShareAction, + Consumer<Boolean> onUpdateSharedTextIsExcluded, + EventLog log, + Consumer</* @Nullable */ Integer> finishCallback) { + mContext = context; + mCopyButtonRunnable = copyButtonRunnable; + mEditButtonRunnable = editButtonRunnable; + mCustomActions = ImmutableList.copyOf(customActions); + mModifyShareAction = modifyShareAction; + mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; + mLog = log; + mFinishCallback = finishCallback; + } + + @Override + @Nullable + public Runnable getEditButtonRunnable() { + return mEditButtonRunnable; + } + + @Override + @Nullable + public Runnable getCopyButtonRunnable() { + return mCopyButtonRunnable; + } + + /** Create custom actions */ + @Override + public List<ActionRow.Action> createCustomActions() { + List<ActionRow.Action> actions = new ArrayList<>(); + for (int i = 0; i < mCustomActions.size(); i++) { + final int position = i; + ActionRow.Action actionRow = createCustomAction( + mContext, + mCustomActions.get(i), + mFinishCallback, + () -> { + mLog.logCustomActionSelected(position); + } + ); + if (actionRow != null) { + actions.add(actionRow); + } + } + return actions; + } + + /** + * Provides a share modification action, if any. + */ + @Override + @Nullable + public ActionRow.Action getModifyShareAction() { + return createCustomAction( + mContext, + mModifyShareAction, + mFinishCallback, + () -> { + mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); + }); + } + + /** + * <p> + * Creates an exclude-text action that can be called when the user changes shared text + * status in the Media + Text preview. + * </p> + * <p> + * <code>true</code> argument value indicates that the text should be excluded. + * </p> + */ + @Override + public Consumer<Boolean> getExcludeSharedTextAction() { + return mExcludeSharedTextAction; + } + + @Nullable + private static Runnable makeCopyButtonRunnable( + Context context, + Intent targetIntent, + String referrerPackageName, + Consumer<Integer> finishCallback, + EventLog log) { + final ClipData clipData; + try { + clipData = extractTextToCopy(targetIntent); + } catch (Throwable t) { + Log.e(TAG, "Failed to extract data to copy", t); + return null; + } + if (clipData == null) { + return null; + } + return () -> { + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( + Context.CLIPBOARD_SERVICE); + clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); + + log.logActionSelected(EventLog.SELECTION_TYPE_COPY); + finishCallback.accept(Activity.RESULT_OK); + }; + } + + @Nullable + private static ClipData extractTextToCopy(Intent targetIntent) { + if (targetIntent == null) { + return null; + } + + final String action = targetIntent.getAction(); + + ClipData clipData = null; + if (Intent.ACTION_SEND.equals(action)) { + String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); + + if (extraText != null) { + clipData = ClipData.newPlainText(null, extraText); + } else { + Log.w(TAG, "No data available to copy to clipboard"); + } + } else { + // expected to only be visible with ACTION_SEND (when a text is shared) + Log.d(TAG, "Action (" + action + ") not supported for copying to clipboard"); + } + return clipData; + } + + private static TargetInfo getEditSharingTarget( + Context context, + Intent originalIntent, + Optional<ComponentName> imageEditor) { + + final Intent resolveIntent = new Intent(originalIntent); + // Retain only URI permission grant flags if present. Other flags may prevent the scene + // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, + // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. + resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); + imageEditor.ifPresent(resolveIntent::setComponent); + resolveIntent.setAction(Intent.ACTION_EDIT); + resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); + String originalAction = originalIntent.getAction(); + if (Intent.ACTION_SEND.equals(originalAction)) { + if (resolveIntent.getData() == null) { + Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + String mimeType = context.getContentResolver().getType(uri); + resolveIntent.setDataAndType(uri, mimeType); + } + } + } else { + Log.e(TAG, originalAction + " is not supported."); + return null; + } + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); + return null; + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ri, + context.getString(R.string.screenshot_edit), + "", + resolveIntent); + dri.getDisplayIconHolder().setDisplayIcon( + context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); + return dri; + } + + private static Runnable makeEditButtonRunnable( + TargetInfo editSharingTarget, + Callable</* @Nullable */ View> firstVisibleImageQuery, + ActionActivityStarter activityStarter, + EventLog log) { + return () -> { + // Log share completion via edit. + log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); + + View firstImageView = null; + try { + firstImageView = firstVisibleImageQuery.call(); + } catch (Exception e) { /* ignore */ } + // Action bar is user-independent; always start as primary. + if (firstImageView == null) { + activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); + } else { + activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); + } + }; + } + + @Nullable + private static ActionRow.Action createCustomAction( + Context context, + ChooserAction action, + Consumer<Integer> finishCallback, + Runnable loggingRunnable) { + if (action == null || action.getAction() == null) { + return null; + } + Drawable icon = action.getIcon().loadDrawable(context); + if (icon == null && TextUtils.isEmpty(action.getLabel())) { + return null; + } + return new ActionRow.Action( + action.getLabel(), + icon, + () -> { + try { + action.getAction().send( + null, + 0, + null, + null, + null, + null, + ActivityOptions.makeCustomAnimation( + context, + R.anim.slide_in_right, + R.anim.slide_out_left) + .toBundle()); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); + } + if (loggingRunnable != null) { + loggingRunnable.run(); + } + finishCallback.accept(Activity.RESULT_OK); + } + ); + } +} 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..70812642 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -0,0 +1,1845 @@ +/* + * 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.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; + +import static java.util.Objects.requireNonNull; + +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.annotation.Nullable; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.ChooserGridLayoutManager; +import com.android.intentresolver.ChooserListAdapter; +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.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.grid.ChooserGridAdapter; +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.v2.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.v2.platform.ImageEditor; +import com.android.intentresolver.v2.platform.NearbyShare; +import com.android.intentresolver.widget.ImagePreviewView; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.content.PackageMonitor; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +import dagger.hilt.android.AndroidEntryPoint; + +import kotlin.Unit; + +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.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +import javax.inject.Inject; + +/** + * The Chooser Activity handles intent resolution specifically for sharing intents - + * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. + * + */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +@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<>(); + + private static final int TARGET_TYPE_DEFAULT = 0; + private static final int TARGET_TYPE_CHOOSER_TARGET = 1; + private static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; + private 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; + + @Inject public FeatureFlags mFeatureFlags; + @Inject public EventLog mEventLog; + @Inject @ImageEditor public Optional<ComponentName> mImageEditor; + @Inject @NearbyShare public Optional<ComponentName> mNearbyShare; + @Inject public TargetDataLoader mTargetDataLoader; + + 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; + + private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); + + @Override + protected void onCreate(Bundle savedInstanceState) { + Tracer.INSTANCE.markLaunched(); + super.onCreate(savedInstanceState); + setLogic(new ChooserActivityLogic( + TAG, + () -> this, + this::onWorkProfileStatusUpdated, + () -> mTargetDataLoader, + this::onPreinitialization)); + addInitializer(this::init); + } + + private void init() { + if (getChooserRequest() == null) { + finish(); + return; + } + if (isFinishing()) { + // Performing a clean exit: + // Skip initializing any additional resources. + return; + } + setTheme(mLogic.getThemeResId()); + + getEventLog().logSharesheetTriggered(); + + 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); + ChooserRequestParameters chooserRequest = requireChooserRequest(); + mChooserContentPreviewUi = new ChooserContentPreviewUi( + getCoroutineScope(getLifecycle()), + previewViewModel.createOrReuseProvider(chooserRequest.getTargetIntent()), + chooserRequest.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 - mIntentReceivedTime.get(); + getEventLog().logChooserActivityShown( + isWorkProfile(), chooserRequest.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( + mLogic.getReferrerPackageName(), + chooserRequest.getTargetType(), + chooserRequest.getCallerChooserTargets().size(), + (chooserRequest.getInitialIntents() == null) + ? 0 : chooserRequest.getInitialIntents().length, + isWorkProfile(), + mChooserContentPreviewUi.getPreferredContentPreview(), + chooserRequest.getTargetAction(), + chooserRequest.getChooserActions().size(), + chooserRequest.getModifyShareAction() != null + ); + + mEnterTransitionAnimationDelegate.postponeTransition(); + } + + protected final Unit onPreinitialization() { + mIntentReceivedTime.set(System.currentTimeMillis()); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + mMaxTargetsPerRow = + getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mShouldDisplayLandscape = + shouldDisplayLandscape(getResources().getConfiguration().orientation); + + + ChooserRequestParameters chooserRequest = getChooserRequest(); + if (chooserRequest == null) { + return Unit.INSTANCE; + } + setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); + + createProfileRecords( + new AppPredictorFactory( + this, + chooserRequest.getSharedText(), + chooserRequest.getTargetIntentFilter() + ), + chooserRequest.getTargetIntentFilter() + ); + return Unit.INSTANCE; + } + + @Nullable + private ChooserRequestParameters getChooserRequest() { + return ((ChooserActivityLogic) mLogic).getChooserRequestParameters(); + } + + private ChooserRequestParameters requireChooserRequest() { + return requireNonNull(getChooserRequest()); + } + + private AnnotatedUserHandles requireAnnotatedUserHandles() { + return requireNonNull(mLogic.getAnnotatedUserHandles()); + } + + private void createProfileRecords( + AppPredictorFactory factory, IntentFilter targetIntentFilter) { + UserHandle mainUserHandle = requireAnnotatedUserHandles().personalProfileUserHandle; + ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); + if (record.shortcutLoader == null) { + Tracer.INSTANCE.endLaunchToShortcutTrace(); + } + + UserHandle workUserHandle = requireAnnotatedUserHandles().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 = requireChooserRequest().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( + requireAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + } + + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + ChooserGridAdapter adapter = createChooserGridAdapter( + /* context */ this, + mLogic.getPayloadIntents(), + initialIntents, + rList, + filterLastUsed, + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + return new ChooserMultiProfilePagerAdapter( + /* context */ this, + adapter, + createEmptyStateProvider(/* workProfileUserHandle= */ null), + /* workProfileQuietModeChecker= */ () -> false, + /* workProfileUserHandle= */ null, + requireAnnotatedUserHandles().cloneProfileUserHandle, + mMaxTargetsPerRow, + mFeatureFlags); + } + + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + int selectedProfile = findSelectedProfile(); + ChooserGridAdapter personalAdapter = createChooserGridAdapter( + /* context */ this, + mLogic.getPayloadIntents(), + selectedProfile == PROFILE_PERSONAL ? initialIntents : null, + rList, + filterLastUsed, + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + ChooserGridAdapter workAdapter = createChooserGridAdapter( + /* context */ this, + mLogic.getPayloadIntents(), + selectedProfile == PROFILE_WORK ? initialIntents : null, + rList, + filterLastUsed, + /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle, + targetDataLoader); + return new ChooserMultiProfilePagerAdapter( + /* context */ this, + personalAdapter, + workAdapter, + createEmptyStateProvider(requireAnnotatedUserHandles().workProfileUserHandle), + () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), + selectedProfile, + requireAnnotatedUserHandles().workProfileUserHandle, + requireAnnotatedUserHandles().cloneProfileUserHandle, + mMaxTargetsPerRow, + mFeatureFlags); + } + + private int findSelectedProfile() { + int selectedProfile = getSelectedProfileExtra(); + if (selectedProfile == -1) { + selectedProfile = getProfileForUser( + requireAnnotatedUserHandles().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) { + mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs(); + } else { + listAdapter.handlePackagesChanged(); + } + updateProfileViewButton(); + } + + @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(); + if (mRefinementManager != null) { + 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) { + ChooserRequestParameters chooserRequest = getChooserRequest(); + if (chooserRequest == null) { + return defIntent; + } + + Intent result = defIntent; + if (chooserRequest.getReplacementExtras() != null) { + final Bundle replExtras = + chooserRequest.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) { + ChooserRequestParameters chooserRequest = requireChooserRequest(); + if (chooserRequest.getChosenComponentSender() != null) { + final ComponentName target = cti.getResolvedComponentName(); + if (target != null) { + final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); + try { + chooserRequest.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() { + ChooserRequestParameters chooserRequest = requireChooserRequest(); + if (!chooserRequest.getCallerChooserTargets().isEmpty()) { + // Send the caller's chooser targets only to the default profile. + UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) + ? requireAnnotatedUserHandles().workProfileUserHandle + : requireAnnotatedUserHandles().personalProfileUserHandle; + if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { + mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( + /* origTarget */ null, + new ArrayList<>(chooserRequest.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() + ? requireChooserRequest().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, + requireChooserRequest().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), + requireChooserRequest().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) { + mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(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 = mLogic.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(requireChooserRequest().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) || (requireAnnotatedUserHandles().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 requireChooserRequest().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) { + ChooserRequestParameters parameters = requireChooserRequest(); + ChooserListAdapter chooserListAdapter = createChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + mLogic.getTargetIntent(), + parameters.getReferrerFillInIntent(), + 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, + Intent referrerFillInIntent, + int maxTargetsPerRow, + TargetDataLoader targetDataLoader) { + UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() + && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) + ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + return new ChooserListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + targetIntent, + referrerFillInIntent, + this, + context.getPackageManager(), + getEventLog(), + maxTargetsPerRow, + initialIntentsUserSpace, + targetDataLoader, + () -> { + ProfileRecord record = getProfileRecord(userHandle); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + }); + } + + @Override + protected Unit onWorkProfileStatusUpdated() { + UserHandle workUser = requireAnnotatedUserHandles().workProfileUserHandle; + ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + return super.onWorkProfileStatusUpdated(); + } + + @Override + @VisibleForTesting + protected ChooserListController createListController(UserHandle userHandle) { + AppPredictor appPredictor = getAppPredictor(userHandle); + AbstractResolverComparator resolverComparator; + if (appPredictor != null) { + resolverComparator = new AppPredictionServiceResolverComparator( + this, + mLogic.getTargetIntent(), + mLogic.getReferrerPackageName(), + appPredictor, + userHandle, + getEventLog(), + mNearbyShare.orElse(null) + ); + } else { + resolverComparator = + new ResolverRankerServiceResolverComparator( + this, + mLogic.getTargetIntent(), + mLogic.getReferrerPackageName(), + null, + getEventLog(), + getResolverRankerServiceUserHandleList(userHandle), + mNearbyShare.orElse(null)); + } + + return new ChooserListController( + this, + mPm, + mLogic.getTargetIntent(), + mLogic.getReferrerPackageName(), + requireAnnotatedUserHandles().userIdOfCallingApp, + resolverComparator, + getQueryIntentsUser(userHandle)); + } + + @VisibleForTesting + protected ViewModelProvider.Factory createPreviewViewModelFactory() { + return PreviewViewModel.Companion.getFactory(); + } + + private ChooserActionFactory createChooserActionFactory() { + ChooserRequestParameters request = requireChooserRequest(); + return new ChooserActionFactory( + this, + request.getTargetIntent(), + request.getReferrerPackageName(), + request.getChooserActions(), + request.getModifyShareAction(), + mImageEditor, + getEventLog(), + (isExcluded) -> mExcludeSharedText = isExcluded, + this::getFirstVisibleImgPreviewView, + new ChooserActionFactory.ActionActivityStarter() { + @Override + public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { + safelyStartActivityAsUser( + targetInfo, + requireAnnotatedUserHandles().personalProfileUserHandle + ); + finish(); + } + + @Override + public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo targetInfo, View sharedElement, String sharedElementName) { + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( + ChooserActivity.this, sharedElement, sharedElementName); + safelyStartActivityAsUser( + targetInfo, + requireAnnotatedUserHandles().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 = + mChooserMultiProfilePagerAdapter.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 another profile, to prevent cropping of the empty state screen we show + * a second row in the current profile. + */ + private boolean shouldShowExtraRow(int rowsToShow) { + return rowsToShow == 1 + && mChooserMultiProfilePagerAdapter + .shouldShowEmptyStateScreenInAnyInactiveAdapter(); + } + + /** + * 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(requireAnnotatedUserHandles().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; + } + + @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() { + ChooserRequestParameters chooserRequest = getChooserRequest(); + return (chooserRequest != null) && chooserRequest.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()); + } + + 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/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt new file mode 100644 index 00000000..7bc39a24 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -0,0 +1,87 @@ +package com.android.intentresolver.v2 + +import android.app.Activity +import android.content.Intent +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.annotation.OpenForTesting +import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.R +import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.v2.util.mutableLazy + +private const val TAG = "ChooserActivityLogic" + +/** + * Activity logic for [ChooserActivity]. + * + * TODO: Make this class no longer open once [ChooserActivity] no longer needs to cast to access + * [chooserRequestParameters]. For now, this class being open is better than using reflection + * there. + */ +@OpenForTesting +open class ChooserActivityLogic( + tag: String, + activityProvider: () -> ComponentActivity, + onWorkProfileStatusUpdated: () -> Unit, + targetDataLoaderProvider: () -> TargetDataLoader, + private val onPreInitialization: () -> Unit, +) : + ActivityLogic, + CommonActivityLogic by CommonActivityLogicImpl( + tag, + activityProvider, + onWorkProfileStatusUpdated, + ) { + + override val targetIntent: Intent by lazy { chooserRequestParameters?.targetIntent ?: Intent() } + + override val resolvingHome: Boolean = false + + override val title: CharSequence? by lazy { chooserRequestParameters?.title } + + override val defaultTitleResId: Int by lazy { + chooserRequestParameters?.defaultTitleResource ?: 0 + } + + override val initialIntents: List<Intent>? by lazy { + chooserRequestParameters?.initialIntents?.toList() + } + + override val supportsAlwaysUseOption: Boolean = false + + override val targetDataLoader: TargetDataLoader by lazy { targetDataLoaderProvider() } + + override val themeResId: Int = R.style.Theme_DeviceDefault_Chooser + + private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } + override val profileSwitchMessage: String? by _profileSwitchMessage + + override val payloadIntents: List<Intent> by lazy { + buildList { + add(targetIntent) + chooserRequestParameters?.additionalTargets?.let { addAll(it) } + } + } + + val chooserRequestParameters: ChooserRequestParameters? by lazy { + try { + ChooserRequestParameters( + (activity as Activity).intent, + referrerPackageName, + (activity as Activity).referrer, + ) + } catch (e: IllegalArgumentException) { + Log.e(tag, "Caller provided invalid Chooser request parameters", e) + null + } + } + + override fun preInitialization() { + onPreInitialization() + } + + override fun clearProfileSwitchMessage() { + _profileSwitchMessage.setLazy(null) + } +} diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java new file mode 100644 index 00000000..de0a9426 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager.widget.PagerAdapter; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRecyclerViewAccessibilityDelegate; +import com.android.intentresolver.FeatureFlags; +import com.android.intentresolver.R; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.grid.ChooserGridAdapter; +import com.android.intentresolver.measurements.Tracer; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. + */ +@VisibleForTesting +public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< + RecyclerView, ChooserGridAdapter, ChooserListAdapter> { + private static final int SINGLE_CELL_SPAN_SIZE = 1; + + private final ChooserProfileAdapterBinder mAdapterBinder; + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; + + public ChooserMultiProfilePagerAdapter( + Context context, + ChooserGridAdapter adapter, + EmptyStateProvider emptyStateProvider, + Supplier<Boolean> workProfileQuietModeChecker, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + int maxTargetsPerRow, + FeatureFlags featureFlags) { + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + ImmutableList.of(adapter), + emptyStateProvider, + workProfileQuietModeChecker, + /* defaultProfile= */ 0, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier(context), + featureFlags); + } + + public ChooserMultiProfilePagerAdapter( + Context context, + ChooserGridAdapter personalAdapter, + ChooserGridAdapter workAdapter, + EmptyStateProvider emptyStateProvider, + Supplier<Boolean> workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + int maxTargetsPerRow, + FeatureFlags featureFlags) { + this( + context, + new ChooserProfileAdapterBinder(maxTargetsPerRow), + ImmutableList.of(personalAdapter, workAdapter), + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier(context), + featureFlags); + } + + private ChooserMultiProfilePagerAdapter( + Context context, + ChooserProfileAdapterBinder adapterBinder, + ImmutableList<ChooserGridAdapter> gridAdapters, + EmptyStateProvider emptyStateProvider, + Supplier<Boolean> workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, + FeatureFlags featureFlags) { + super( + gridAdapter -> gridAdapter.getListAdapter(), + adapterBinder, + gridAdapters, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + () -> makeProfileView(context, featureFlags), + bottomPaddingOverrideSupplier); + mAdapterBinder = adapterBinder; + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); + } + + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); + setupContainerPadding(); + } + + /** + * Notify adapter about the drawer's collapse state. This will affect the app divider's + * visibility. + */ + public void setIsCollapsed(boolean isCollapsed) { + for (int i = 0, size = getItemCount(); i < size; i++) { + getAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); + } + } + + private static ViewGroup makeProfileView( + Context context, FeatureFlags featureFlags) { + LayoutInflater inflater = LayoutInflater.from(context); + ViewGroup rootView = featureFlags.scrollablePreview() + ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) + : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); + RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); + recyclerView.setAccessibilityDelegateCompat( + new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); + return rootView; + } + + @Override + public boolean onHandlePackagesChanged( + ChooserListAdapter listAdapter, boolean waitingToEnableWorkProfile) { + // TODO: why do we need to do the extra `notifyDataSetChanged()` in (only) the Chooser case? + getActiveListAdapter().notifyDataSetChanged(); + return super.onHandlePackagesChanged(listAdapter, waitingToEnableWorkProfile); + } + + @Override + protected final boolean rebuildTab(ChooserListAdapter listAdapter, boolean doPostProcessing) { + if (doPostProcessing) { + Tracer.INSTANCE.beginAppTargetLoadingSection(listAdapter.getUserHandle()); + } + return super.rebuildTab(listAdapter, doPostProcessing); + } + + /** Apply the specified {@code height} as the footer in each tab's adapter. */ + public void setFooterHeightInEveryAdapter(int height) { + for (int i = 0; i < getItemCount(); ++i) { + getAdapterForIndex(i).setFooterHeight(height); + } + } + + private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { + private final Context mContext; + private int mBottomOffset; + + BottomPaddingOverrideSupplier(Context context) { + mContext = context; + } + + public void setEmptyStateBottomOffset(int bottomOffset) { + mBottomOffset = bottomOffset; + } + + @Override + public Optional<Integer> get() { + int initialBottomPadding = mContext.getResources().getDimensionPixelSize( + R.dimen.resolver_empty_state_container_padding_bottom); + return Optional.of(initialBottomPadding + mBottomOffset); + } + } + + private static class ChooserProfileAdapterBinder implements + AdapterBinder<RecyclerView, ChooserGridAdapter> { + private int mMaxTargetsPerRow; + + ChooserProfileAdapterBinder(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } + + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } + + @Override + public void bind( + RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) { + GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); + glm.setSpanCount(mMaxTargetsPerRow); + glm.setSpanSizeLookup( + new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + return chooserGridAdapter.shouldCellSpan(position) + ? SINGLE_CELL_SPAN_SIZE + : glm.getSpanCount(); + } + }); + } + } +} diff --git a/java/src/com/android/intentresolver/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/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java new file mode 100644 index 00000000..2d9be816 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java @@ -0,0 +1,666 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2; + +import android.annotation.IntDef; +import android.annotation.Nullable; +import android.os.Trace; +import android.os.UserHandle; +import android.view.View; +import android.view.ViewGroup; + +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.v2.emptystate.EmptyStateUiHelper; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). + * <p> + * TODO: attempt to further restrict visibility/improve encapsulation in the methods we expose. + * <p> + * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" + * <p> + * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident + * waiting to happen since clients seem to make assumptions about which adapter will be "active" in + * a particular context, and more explicit APIs would make sure those were valid. + * <p> + * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) + * <p> + * TODO: this is part of an in-progress refactor to merge with `GenericMultiProfilePagerAdapter`. + * As originally noted there, we've reduced explicit references to the `ResolverListAdapter` base + * type and may be able to drop the type constraint. + * + * @param <PageViewT> the type of the widget that represents the contents of a page in this adapter + * @param <SinglePageAdapterT> the type of a "root" adapter class to be instantiated and included in + * the per-profile records. + * @param <ListAdapterT> the concrete type of a {@link ResolverListAdapter} implementation to + * control the contents of a given per-profile list. This is provided for convenience, since it must + * be possible to get the list adapter from the page adapter via our + * <code>mListAdapterExtractor</code>. + */ +public class MultiProfilePagerAdapter< + PageViewT extends ViewGroup, + SinglePageAdapterT, + ListAdapterT extends ResolverListAdapter> extends PagerAdapter { + + /** + * Delegate to set up a given adapter and page view to be used together. + * @param <PageViewT> (as in {@link MultiProfilePagerAdapter}). + * @param <SinglePageAdapterT> (as in {@link MultiProfilePagerAdapter}). + */ + public interface AdapterBinder<PageViewT, SinglePageAdapterT> { + /** + * The given {@code view} will be associated with the given {@code adapter}. Do any work + * necessary to configure them compatibly, introduce them to each other, etc. + */ + void bind(PageViewT view, SinglePageAdapterT adapter); + } + + public static final int PROFILE_PERSONAL = 0; + public static final int PROFILE_WORK = 1; + + @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) + public @interface Profile {} + + private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; + private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder; + private final Supplier<ViewGroup> mPageViewInflater; + + private final ImmutableList<ProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems; + + private final EmptyStateProvider mEmptyStateProvider; + private final UserHandle mWorkProfileUserHandle; + private final UserHandle mCloneProfileUserHandle; + private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet. + + private Set<Integer> mLoadedPages; + private int mCurrentPage; + private OnProfileSelectedListener mOnProfileSelectedListener; + + protected MultiProfilePagerAdapter( + Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor, + AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder, + ImmutableList<SinglePageAdapterT> adapters, + EmptyStateProvider emptyStateProvider, + Supplier<Boolean> workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + Supplier<ViewGroup> pageViewInflater, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + mCurrentPage = defaultProfile; + mLoadedPages = new HashSet<>(); + mWorkProfileUserHandle = workProfileUserHandle; + mCloneProfileUserHandle = cloneProfileUserHandle; + mEmptyStateProvider = emptyStateProvider; + mWorkProfileQuietModeChecker = workProfileQuietModeChecker; + + mListAdapterExtractor = listAdapterExtractor; + mAdapterBinder = adapterBinder; + mPageViewInflater = pageViewInflater; + + ImmutableList.Builder<ProfileDescriptor<PageViewT, SinglePageAdapterT>> items = + new ImmutableList.Builder<>(); + for (SinglePageAdapterT adapter : adapters) { + items.add(createProfileDescriptor(adapter, containerBottomPaddingOverrideSupplier)); + } + mItems = items.build(); + } + + private ProfileDescriptor<PageViewT, SinglePageAdapterT> createProfileDescriptor( + SinglePageAdapterT adapter, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + return new ProfileDescriptor<>( + mPageViewInflater.get(), adapter, containerBottomPaddingOverrideSupplier); + } + + public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { + mOnProfileSelectedListener = listener; + } + + /** + * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets + * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed + * page and rebuilds the list. + */ + public void setupViewPager(ViewPager viewPager) { + viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + mCurrentPage = position; + if (!mLoadedPages.contains(position)) { + rebuildActiveTab(true); + mLoadedPages.add(position); + } + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfileSelected(position); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageStateChanged(state); + } + } + }); + viewPager.setAdapter(this); + viewPager.setCurrentItem(mCurrentPage); + mLoadedPages.add(mCurrentPage); + } + + public void clearInactiveProfileCache() { + if (mLoadedPages.size() == 1) { + return; + } + mLoadedPages.remove(1 - mCurrentPage); + } + + @Override + public final ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + final ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem(position); + container.addView(descriptor.mRootView); + return descriptor.mRootView; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object view) { + container.removeView((View) view); + } + + @Override + public int getCount() { + return getItemCount(); + } + + public int getCurrentPage() { + return mCurrentPage; + } + + public final @Profile int getActiveProfile() { + // TODO: here and elsewhere in this class, distinguish between a "profile ID" integer and + // its mapped "page index." When we support more than two profiles, this won't be a "stable + // mapping" -- some particular profile may not be represented by a "page," but the ones that + // are will be assigned contiguous page numbers that skip over the holes. + return getCurrentPage(); + } + + @VisibleForTesting + public UserHandle getCurrentUserHandle() { + return getActiveListAdapter().getUserHandle(); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + + @Override + public CharSequence getPageTitle(int position) { + return null; + } + + public UserHandle getCloneUserHandle() { + return mCloneProfileUserHandle; + } + + /** + * Returns the {@link ProfileDescriptor} relevant to the given <code>pageIndex</code>. + * <ul> + * <li>For a device with only one user, <code>pageIndex</code> value of + * <code>0</code> would return the personal profile {@link ProfileDescriptor}.</li> + * <li>For a device with a work profile, <code>pageIndex</code> value of <code>0</code> would + * return the personal profile {@link ProfileDescriptor}, and <code>pageIndex</code> value of + * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li> + * </ul> + */ + private ProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) { + return mItems.get(pageIndex); + } + + private ViewGroup getEmptyStateView(int pageIndex) { + return getItem(pageIndex).getEmptyStateView(); + } + + public ViewGroup getActiveEmptyStateView() { + return getEmptyStateView(getCurrentPage()); + } + + /** + * Returns the number of {@link ProfileDescriptor} objects. + * <p>For a normal consumer device with only one user returns <code>1</code>. + * <p>For a device with a work profile returns <code>2</code>. + */ + public final int getItemCount() { + return mItems.size(); + } + + public final PageViewT getListViewForIndex(int index) { + return getItem(index).mView; + } + + /** + * Returns the adapter of the list view for the relevant page specified by + * <code>pageIndex</code>. + * <p>This method is meant to be implemented with an implementation-specific return type + * depending on the adapter type. + */ + @VisibleForTesting + public final SinglePageAdapterT getAdapterForIndex(int index) { + return getItem(index).mAdapter; + } + + /** + * Performs view-related initialization procedures for the adapter specified + * by <code>pageIndex</code>. + */ + public final void setupListAdapter(int pageIndex) { + mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that represents + * <code>userHandle</code>. If there is no such adapter for the specified + * <code>userHandle</code>, returns {@code null}. + * <p>For example, if there is a work profile on the device with user id 10, calling this method + * with <code>UserHandle.of(10)</code> returns the work profile {@link ListAdapterT}. + */ + @Nullable + public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + if (getPersonalListAdapter().getUserHandle().equals(userHandle) + || userHandle.equals(getCloneUserHandle())) { + return getPersonalListAdapter(); + } else if ((getWorkListAdapter() != null) + && getWorkListAdapter().getUserHandle().equals(userHandle)) { + return getWorkListAdapter(); + } + return null; + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that is currently visible + * to the user. + * <p>For example, if the user is viewing the work tab in the share sheet, this method returns + * the work profile {@link ListAdapterT}. + * @see #getInactiveListAdapter() + */ + @VisibleForTesting + public final ListAdapterT getActiveListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); + } + + /** + * If this is a device with a work profile, returns the {@link ListAdapterT} instance + * of the profile that is <b><i>not</i></b> currently visible to the user. Otherwise returns + * {@code null}. + * <p>For example, if the user is viewing the work tab in the share sheet, this method returns + * the personal profile {@link ListAdapterT}. + * @see #getActiveListAdapter() + */ + @VisibleForTesting + @Nullable + public final ListAdapterT getInactiveListAdapter() { + if (getCount() < 2) { + return null; + } + return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); + } + + public final ListAdapterT getPersonalListAdapter() { + return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); + } + + /** @return whether our tab data contains a page for the specified {@code profile} ID. */ + public final boolean hasPageForProfile(@Profile int profile) { + // TODO: here and elsewhere in this class, distinguish between a "profile ID" integer and + // its mapped "page index." When we support more than two profiles, this won't be a "stable + // mapping" -- some particular profile may not be represented by a "page," but the ones that + // are will be assigned contiguous page numbers that skip over the holes. + return hasAdapterForIndex(profile); + } + + @Nullable + public final ListAdapterT getWorkListAdapter() { + if (!hasAdapterForIndex(PROFILE_WORK)) { + return null; + } + return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); + } + + public final SinglePageAdapterT getCurrentRootAdapter() { + return getAdapterForIndex(getCurrentPage()); + } + + public final PageViewT getActiveAdapterView() { + return getListViewForIndex(getCurrentPage()); + } + + @Nullable + public final PageViewT getInactiveAdapterView() { + if (getCount() < 2) { + return null; + } + return getListViewForIndex(1 - getCurrentPage()); + } + + private boolean anyAdapterHasItems() { + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = mListAdapterExtractor.apply(getAdapterForIndex(i)); + if (listAdapter.getCount() > 0) { + return true; + } + } + return false; + } + + public void refreshPackagesInAllTabs() { + // TODO: handle all inactive profiles; for now we can only have at most one. It's unclear if + // this legacy logic really requires the active tab to be rebuilt first, or if we could just + // iterate over the tabs in arbitrary order. + getActiveListAdapter().handlePackagesChanged(); + if (getCount() > 1) { + getInactiveListAdapter().handlePackagesChanged(); + } + } + + /** + * Notify that there has been a package change which could potentially modify the set of targets + * that should be shown in the specified {@code listAdapter}. This <em>may</em> result in + * "rebuilding" the target list for that adapter. + * + * @param listAdapter an adapter that may need to be updated after the package-change event. + * @param waitingToEnableWorkProfile whether we've turned on the work profile, but haven't yet + * seen an {@code ACTION_USER_UNLOCKED} broadcast. In this case we skip the rebuild of any + * work-profile adapter because we wouldn't expect meaningful results -- but another rebuild + * will be prompted when we eventually get the broadcast. + * + * @return whether we're able to proceed with a Sharesheet session after processing this + * package-change event. If false, we were able to rebuild the targets but determined that there + * aren't any we could present in the UI without the app looking broken, so we should just quit. + */ + public boolean onHandlePackagesChanged( + ListAdapterT listAdapter, boolean waitingToEnableWorkProfile) { + if (listAdapter == getActiveListAdapter()) { + if (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && waitingToEnableWorkProfile) { + // We have just turned on the work profile and entered the passcode 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 true; + } + + boolean listRebuilt = rebuildActiveTab(true); + if (listRebuilt) { + listAdapter.notifyDataSetChanged(); + } + + // TODO: shouldn't we check that the inactive tabs are built before declaring that we + // have to quit for lack of items? + return anyAdapterHasItems(); + } else { + clearInactiveProfileCache(); + return true; + } + } + + /** + * Fully-rebuild the active tab and, if specified, partially-rebuild any other inactive tabs. + */ + public boolean rebuildTabs(boolean includePartialRebuildOfInactiveTabs) { + // TODO: we may be able to determine `includePartialRebuildOfInactiveTabs` ourselves as + // a function of our own instance state. OTOH the purpose of this "partial rebuild" is to + // be able to evaluate the intermediate state of one particular profile tab (i.e. work + // profile) that may not generalize well when we have other "inactive tabs." I.e., either we + // rebuild *all* the inactive tabs just to evaluate some auto-launch conditions that only + // depend on personal and/or work tabs, or we have to explicitly specify the ones we care + // about. It's not the pager-adapter's business to know "which ones we care about," so maybe + // they should be rebuilt lazily when-and-if it comes up (e.g. during the evaluation of + // autolaunch conditions). + boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded(); + if (includePartialRebuildOfInactiveTabs) { + boolean rebuildInactiveCompleted = + rebuildInactiveTab(false) || getInactiveListAdapter().isTabLoaded(); + rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; + } + return rebuildCompleted; + } + + /** + * Rebuilds the tab that is currently visible to the user. + * <p>Returns {@code true} if rebuild has completed. + */ + public final boolean rebuildActiveTab(boolean doPostProcessing) { + Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); + boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); + Trace.endSection(); + return result; + } + + /** + * Rebuilds the tab that is not currently visible to the user, if such one exists. + * <p>Returns {@code true} if rebuild has completed. + */ + private boolean rebuildInactiveTab(boolean doPostProcessing) { + Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); + if (getItemCount() == 1) { + Trace.endSection(); + return false; + } + boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing); + Trace.endSection(); + return result; + } + + private int userHandleToPageIndex(UserHandle userHandle) { + if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { + return PROFILE_PERSONAL; + } else { + return PROFILE_WORK; + } + } + + protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { + if (shouldSkipRebuild(activeListAdapter)) { + activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); + return false; + } + return activeListAdapter.rebuildList(doPostProcessing); + } + + private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) { + EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); + return emptyState != null && emptyState.shouldSkipDataRebuild(); + } + + private boolean hasAdapterForIndex(int pageIndex) { + return (pageIndex < getCount()); + } + + /** + * The empty state screens are shown according to their priority: + * <ol> + * <li>(highest priority) cross-profile disabled by policy (handled in + * {@link #rebuildTab(ListAdapterT, boolean)})</li> + * <li>no apps available</li> + * <li>(least priority) work is off</li> + * </ol> + * + * The intention is to prevent the user from having to turn + * the work profile on if there will not be any apps resolved + * anyway. + * + * TODO: move this comment to the place where we configure our composite provider. + */ + public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { + final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); + + if (emptyState == null) { + return; + } + + emptyState.onEmptyStateShown(); + + View.OnClickListener clickListener = null; + + if (emptyState.getButtonClickListener() != null) { + clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( + userHandleToPageIndex(listAdapter.getUserHandle())); + descriptor.mEmptyStateUi.showSpinner(); + }); + } + + showEmptyState(listAdapter, emptyState, clickListener); + } + + /** + * Class to get user id of the current process + */ + public static class MyUserIdProvider { + /** + * @return user id of the current process + */ + public int getMyUserId() { + return UserHandle.myUserId(); + } + } + + private void showEmptyState( + ListAdapterT activeListAdapter, + EmptyState emptyState, + View.OnClickListener buttonOnClick) { + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( + userHandleToPageIndex(activeListAdapter.getUserHandle())); + descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick); + activeListAdapter.markTabLoaded(); + } + + /** + * Sets up the padding of the view containing the empty state screens for the current adapter + * view. + */ + protected final void setupContainerPadding() { + getItem(getCurrentPage()).setupContainerPadding(); + } + + public void showListView(ListAdapterT activeListAdapter) { + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( + userHandleToPageIndex(activeListAdapter.getUserHandle())); + descriptor.mEmptyStateUi.hide(); + } + + /** + * @return whether any "inactive" tab's adapter would show an empty-state screen in our current + * application state. + */ + public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() { + if (getCount() < 2) { + return false; + } + // TODO: check against *any* inactive adapter; for now we only have one. + return shouldShowEmptyStateScreen(getInactiveListAdapter()); + } + + public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { + int count = listAdapter.getUnfilteredCount(); + return (count == 0 && listAdapter.getPlaceholderCount() == 0) + || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && mWorkProfileQuietModeChecker.get()); + } + + // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" + // should be the owner of all per-profile data (especially now that the API is generic)? + private static class ProfileDescriptor<PageViewT, SinglePageAdapterT> { + final ViewGroup mRootView; + final EmptyStateUiHelper mEmptyStateUi; + + // TODO: post-refactoring, we may not need to retain these ivars directly (since they may + // be encapsulated within the `EmptyStateUiHelper`?). + private final ViewGroup mEmptyStateView; + + private final SinglePageAdapterT mAdapter; + private final PageViewT mView; + + ProfileDescriptor( + ViewGroup rootView, + SinglePageAdapterT adapter, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + mRootView = rootView; + mAdapter = adapter; + mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); + mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); + mEmptyStateUi = new EmptyStateUiHelper( + rootView, + com.android.internal.R.id.resolver_list, + containerBottomPaddingOverrideSupplier); + } + + protected ViewGroup getEmptyStateView() { + return mEmptyStateView; + } + + private void setupContainerPadding() { + mEmptyStateUi.setupContainerPadding(); + } + } + + /** Listener interface for changes between the per-profile UI tabs. */ + public interface OnProfileSelectedListener { + /** + * Callback for when the user changes the active tab from personal to work or vice versa. + * <p>This callback is only called when the intent resolver or share sheet shows + * the work and personal profiles. + * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or + * {@link #PROFILE_WORK} if the work profile was selected. + */ + void onProfileSelected(int profileIndex); + + + /** + * Callback for when the scroll state changes. Useful for discovering when the user begins + * dragging, when the pager is automatically settling to the current page, or when it is + * fully stopped/idle. + * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} + * or {@link ViewPager#SCROLL_STATE_SETTLING} + * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged + */ + void onProfilePageStateChanged(int state); + } + + /** + * Listener for when the user switches on the work profile from the work tab. + */ + public interface OnSwitchOnWorkSelectedListener { + /** + * Callback for when the user switches on the work profile from the work tab. + */ + void onSwitchOnWorkSelected(); + } +} 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..2ba50ec3 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -0,0 +1,2181 @@ +/* + * 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.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.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 static java.util.Collections.emptyList; +import static java.util.Objects.requireNonNull; +import static java.util.Objects.requireNonNullElse; + +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.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.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.annotation.UiThread; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.AnnotatedUserHandles; +import com.android.intentresolver.R; +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.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.icons.TargetDataLoader; +import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.MyUserIdProvider; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.v2.MultiProfilePagerAdapter.Profile; +import com.android.intentresolver.v2.data.repository.DevicePolicyResources; +import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; +import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.v2.ui.ActionTitle; +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 kotlin.Unit; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * 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 { + + private final List<Runnable> mInit = new ArrayList<>(); + + protected ActivityLogic mLogic; + + private DevicePolicyResources mDevicePolicyResources; + + public ResolverActivity() { + mIsIntentPicker = getClass().equals(ResolverActivity.class); + } + + protected ResolverActivity(boolean isIntentPicker) { + mIsIntentPicker = isIntentPicker; + } + + private Button mAlwaysButton; + private Button mOnceButton; + protected View mProfileView; + private int mLastSelected = AbsListView.INVALID_POSITION; + private int mLayoutId; + private PickTargetOptionRequest mPickOptionRequest; + // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. + private final boolean mIsIntentPicker; + 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; + + @VisibleForTesting + protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; + + + // 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; + + @Nullable + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); + + 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; + } + }; + } + protected interface Initializer { + void initialize(ActivityLogic value); + } + + protected void setLogic(ActivityLogic logic) { + mLogic = logic; + } + + protected void addInitializer(Runnable initializer) { + mInit.add(initializer); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (isFinishing()) { + // Performing a clean exit: + // Skip initializing anything. + return; + } + mDevicePolicyResources = new DevicePolicyResources(getApplication().getResources(), + requireNonNull(getSystemService(DevicePolicyManager.class))); + setLogic(new ResolverActivityLogic( + TAG, + () -> this, + this::onWorkProfileStatusUpdated)); + addInitializer(this::init); + } + + @Override + protected final void onPostCreate(@Nullable Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + mInit.forEach(Runnable::run); + + if (savedInstanceState != null) { + 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 void init() { + setTheme(mLogic.getThemeResId()); + mLogic.preInitialization(); + + Intent intent = mLogic.getTargetIntent(); + List<Intent> initialIntents = mLogic.getInitialIntents(); + TargetDataLoader targetDataLoader = mLogic.getTargetDataLoader(); + + // Calling UID did not have valid permissions + if (mLogic.getAnnotatedUserHandles() == null) { + finish(); + return; + } + + mPm = getPackageManager(); + + // 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 multiple tabs are 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 = mLogic.getSupportsAlwaysUseOption() && !isVoiceInteraction() + && !shouldShowTabs() && !hasCloneProfile(); + mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), + /* resolutionList = */ null, + filterLastUsed, + targetDataLoader + ); + if (configureContentView(targetDataLoader)) { + return; + } + + mPersonalPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + requireAnnotatedUserHandles().personalProfileUserHandle, + false + ); + if (hasWorkProfile()) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, + getMainLooper(), + requireAnnotatedUserHandles().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( + requireAnnotatedUserHandles().personalProfileUserHandle, + noWorkToPersonalEmptyState, + noPersonalToWorkEmptyState, + createCrossProfileIntentsChecker(), + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); + } + + /** + * 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() + && !mLogic.getResolvingHome() && !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? + mLogic.getWorkProfileAvailabilityManager().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 (mLogic.getResolvingHome() && hasManagedProfile() && !supportsManagedProfiles(ri)) { + String launcherName = ri.activityInfo.loadLabel(getPackageManager()).toString(); + Toast.makeText(this, + mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), + Toast.LENGTH_LONG).show(); + return; + } + + TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(which, hasIndexBeenFiltered); + if (target == null) { + return; + } + if (onTargetSelected(target, always)) { + if (always && mLogic.getSupportsAlwaysUseOption()) { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); + } else if (mLogic.getSupportsAlwaysUseOption()) { + 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 && (mLogic.getSupportsAlwaysUseOption() + || 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, + mLogic.getTargetIntent(), + mLogic.getReferrerPackageName(), + null, + null, + getResolverRankerServiceUserHandleList(userHandle), + null); + return new ResolverListController( + this, + mPm, + mLogic.getTargetIntent(), + mLogic.getReferrerPackageName(), + requireAnnotatedUserHandles().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 (!mLogic.getSupportsAlwaysUseOption()) { + 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 final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (!mMultiProfilePagerAdapter.onHandlePackagesChanged( + listAdapter, + mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile())) { + // We no longer have any items... just finish the activity. + finish(); + } + } + + protected void maybeLogProfileChange() {} + + // @NonFinalForTesting + @VisibleForTesting + protected MyUserIdProvider createMyUserIdProvider() { + return new MyUserIdProvider(); + } + + // @NonFinalForTesting + @VisibleForTesting + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + return new CrossProfileIntentsChecker(getContentResolver()); + } + + protected Unit onWorkProfileStatusUpdated() { + if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( + requireAnnotatedUserHandles().workProfileUserHandle)) { + mMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + return Unit.INSTANCE; + } + + // @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(requireAnnotatedUserHandles().personalProfileUserHandle) + ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + return new ResolverListAdapter( + context, + payloadIntents, + initialIntents, + resolutionList, + filterLastUsed, + createListController(userHandle), + userHandle, + mLogic.getTargetIntent(), + this, + initialIntentsUserSpace, + targetDataLoader); + } + + 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, + mLogic.getWorkProfileAvailabilityManager(), + /* onSwitchOnWorkSelectedListener= */ + () -> { + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + }, + getMetricsCategory()); + + final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + this, + workProfileUserHandle, + requireAnnotatedUserHandles().personalProfileUserHandle, + getMetricsCategory(), + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch + ); + + // Return composite provider, the order matters (the higher, the more priority) + return new CompositeEmptyStateProvider( + blockerEmptyStateProvider, + workProfileOffEmptyStateProvider, + noAppsEmptyStateProvider + ); + } + + private ResolverMultiProfilePagerAdapter + createResolverMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean filterLastUsed, + TargetDataLoader targetDataLoader) { + ResolverListAdapter adapter = createResolverListAdapter( + /* context */ this, + mLogic.getPayloadIntents(), + initialIntents, + resolutionList, + filterLastUsed, + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + adapter, + createEmptyStateProvider(/* workProfileUserHandle= */ null), + /* workProfileQuietModeChecker= */ () -> false, + /* workProfileUserHandle= */ null, + requireAnnotatedUserHandles().cloneProfileUserHandle); + } + + private UserHandle getIntentUser() { + return getIntent().hasExtra(EXTRA_CALLING_USER) + ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) + : requireAnnotatedUserHandles().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 (!requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { + if (requireAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { + selectedProfile = PROFILE_PERSONAL; + } else if (requireAnnotatedUserHandles().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, + mLogic.getPayloadIntents(), + selectedProfile == PROFILE_PERSONAL ? initialIntents : null, + resolutionList, + (filterLastUsed && UserHandle.myUserId() + == requireAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, + targetDataLoader); + UserHandle workProfileUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; + ResolverListAdapter workAdapter = createResolverListAdapter( + /* context */ this, + mLogic.getPayloadIntents(), + selectedProfile == PROFILE_WORK ? initialIntents : null, + resolutionList, + (filterLastUsed && UserHandle.myUserId() + == workProfileUserHandle.getIdentifier()), + /* userHandle */ workProfileUserHandle, + targetDataLoader); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + personalAdapter, + workAdapter, + createEmptyStateProvider(workProfileUserHandle), + () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), + selectedProfile, + workProfileUserHandle, + requireAnnotatedUserHandles().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 = requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + UserHandle personalUser = requireAnnotatedUserHandles().personalProfileUserHandle; + return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; + } + + private AnnotatedUserHandles requireAnnotatedUserHandles() { + return requireNonNull(mLogic.getAnnotatedUserHandles()); + } + + private boolean hasWorkProfile() { + return requireAnnotatedUserHandles().workProfileUserHandle != null; + } + + private boolean hasCloneProfile() { + return requireAnnotatedUserHandles().cloneProfileUserHandle != null; + } + + protected final boolean isLaunchedAsCloneProfile() { + UserHandle launchUser = requireAnnotatedUserHandles().userHandleSharesheetLaunchedAs; + UserHandle cloneUser = requireAnnotatedUserHandles().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. + mLogic.clearProfileSwitchMessage(); + + 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( + requireAnnotatedUserHandles().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); + } + + @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); + } + } + + protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { + final ActionTitle title = mLogic.getResolvingHome() + ? 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(), + requireAnnotatedUserHandles().personalProfileUserHandle, + false); + if (hasWorkProfile()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + requireAnnotatedUserHandles().workProfileUserHandle, + false); + } + mRegistered = true; + } + WorkProfileAvailabilityManager workProfileAvailabilityManager = + mLogic.getWorkProfileAvailabilityManager(); + if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { + if (workProfileAvailabilityManager.isQuietModeEnabled()) { + workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); + } + } + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + updateProfileViewButton(); + } + + @Override + protected final void onStart() { + super.onStart(); + + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + if (hasWorkProfile()) { + mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); + } + } + + @Override + protected final void onSaveInstanceState(@NonNull 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()); + } + } + + 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); + } + + @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. + String profileSwitchMessage = mLogic.getProfileSwitchMessage(); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + onActivityStarted(cti); + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + requireAnnotatedUserHandles().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. + // To date, we really only care about "partially rebuilding" tabs for work and/or personal. + boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildTabs(shouldShowTabs()); + + 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); + + // TODO: try to dedupe and use the pager's `getActiveProfile()` instead of the activity + // `getCurrentProfile()` (or align them if they're not currently equivalent). If they truly + // need to be distinct here, then `getCurrentProfile()` should at *least* get a more + // specific name -- but note that checking `getCurrentProfile()` here, then following + // `getActiveProfile()` to find the "in/active adapter," is exactly the legacy behavior. + boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; + + ResolverListAdapter sameProfileAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + DisplayResolveInfo sameProfileResolveInfo = sameProfileAdapter.getFirstDisplayResolveInfo(); + + 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(); + }); + } + + private boolean isTwoPagePersonalAndWorkConfiguration() { + return (mMultiProfilePagerAdapter.getCount() == 2) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); + } + + /** + * Mini resolver should be used when all of the following are true: + * 1. This is the intent picker (ResolverActivity). + * 2. There are exactly two tabs, for the "personal" and "work" profiles. + * 3. This profile only has web browser matches. + * 4. The other profile has a single non-browser match. + */ + private boolean shouldUseMiniResolver() { + if (!mIsIntentPicker) { + return false; + } + if (!isTwoPagePersonalAndWorkConfiguration()) { + return false; + } + + ResolverListAdapter sameProfileAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter otherProfileAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + 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 (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 just 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() { + if (!isTwoPagePersonalAndWorkConfiguration()) { + return false; + } + + ResolverListAdapter activeListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; + } + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (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(requireAnnotatedUserHandles().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(mDevicePolicyResources.getPersonalTabLabel()); + personalButton.setContentDescription( + mDevicePolicyResources.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(mDevicePolicyResources.getWorkTabLabel()); + workButton.setContentDescription(mDevicePolicyResources.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 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; + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .clearCheckedItemsInInactiveProfiles(); + } + + 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 (mLogic.getSupportsAlwaysUseOption()) { + 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 = mLogic.getTitle() != null + ? mLogic.getTitle() + : getTitleForAction(mLogic.getTargetIntent(), mLogic.getDefaultTitleResId()); + + 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( + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch + ).hasFilteredItem(); + return mLogic.getSupportsAlwaysUseOption() && 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; + } + + 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 requireAnnotatedUserHandles().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(requireAnnotatedUserHandles().personalProfileUserHandle) + && hasCloneProfile()) { + userList.add(requireAnnotatedUserHandles().cloneProfileUserHandle); + } + return userList; + } + + private CharSequence getOrLoadDisplayLabel(TargetInfo info) { + if (info.isDisplayResolveInfo()) { + mLogic.getTargetDataLoader().getOrLoadLabel((DisplayResolveInfo) info); + } + CharSequence displayLabel = info.getDisplayLabel(); + return displayLabel == null ? "" : displayLabel; + } +} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt new file mode 100644 index 00000000..0e2b25ec --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -0,0 +1,81 @@ +package com.android.intentresolver.v2 + +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.annotation.OpenForTesting +import com.android.intentresolver.R +import com.android.intentresolver.icons.DefaultTargetDataLoader +import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.v2.util.mutableLazy + +/** Activity logic for [ResolverActivity]. */ +@OpenForTesting +open class ResolverActivityLogic( + tag: String, + activityProvider: () -> ComponentActivity, + onWorkProfileStatusUpdated: () -> Unit, +) : + ActivityLogic, + CommonActivityLogic by CommonActivityLogicImpl( + tag, + activityProvider, + onWorkProfileStatusUpdated, + ) { + + override val targetIntent: Intent by lazy { + val intent = Intent(activity.intent) + 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.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS.inv()) + + // 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.flags and Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT != 0) { + intent.setFlags(intent.flags and Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT.inv()) + } + intent + } + + override val resolvingHome: Boolean by lazy { + targetIntent.action == Intent.ACTION_MAIN && + targetIntent.categories.singleOrNull() == Intent.CATEGORY_HOME + } + + override val title: CharSequence? = null + + override val defaultTitleResId: Int = 0 + + override val initialIntents: List<Intent>? = null + + override val supportsAlwaysUseOption: Boolean = true + + override val targetDataLoader: TargetDataLoader by lazy { + DefaultTargetDataLoader( + activity, + activity.lifecycle, + activity.intent.getBooleanExtra( + ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, + /* defaultValue = */ false, + ), + ) + } + + override val themeResId: Int = R.style.Theme_DeviceDefault_Resolver + + private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } + override val profileSwitchMessage: String? by _profileSwitchMessage + + override val payloadIntents: List<Intent> by lazy { listOf(targetIntent) } + + override fun preInitialization() { + // Do nothing + } + + override fun clearProfileSwitchMessage() { + _profileSwitchMessage.setLazy(null) + } +} diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java new file mode 100644 index 00000000..d96fd15a --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2; + +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.ListView; + +import androidx.viewpager.widget.PagerAdapter; + +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. + */ +@VisibleForTesting +public class ResolverMultiProfilePagerAdapter extends + MultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> { + private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; + + public ResolverMultiProfilePagerAdapter( + Context context, + ResolverListAdapter adapter, + EmptyStateProvider emptyStateProvider, + Supplier<Boolean> workProfileQuietModeChecker, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { + this( + context, + ImmutableList.of(adapter), + emptyStateProvider, + workProfileQuietModeChecker, + /* defaultProfile= */ 0, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier()); + } + + 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), + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + new BottomPaddingOverrideSupplier()); + } + + private ResolverMultiProfilePagerAdapter( + Context context, + ImmutableList<ResolverListAdapter> listAdapters, + EmptyStateProvider emptyStateProvider, + Supplier<Boolean> workProfileQuietModeChecker, + @Profile int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + super( + listAdapter -> listAdapter, + (listView, bindAdapter) -> listView.setAdapter(bindAdapter), + listAdapters, + emptyStateProvider, + workProfileQuietModeChecker, + defaultProfile, + workProfileUserHandle, + cloneProfileUserHandle, + () -> (ViewGroup) LayoutInflater.from(context).inflate( + R.layout.resolver_list_per_profile, null, false), + bottomPaddingOverrideSupplier); + mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; + } + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); + } + + /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */ + public void clearCheckedItemsInInactiveProfiles() { + // TODO: apply to all inactive adapters; for now we just have the one. + ListView inactiveListView = getInactiveAdapterView(); + if (inactiveListView.getCheckedItemCount() > 0) { + inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); + } + } + + private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { + private boolean mUseLayoutWithDefault; + + public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mUseLayoutWithDefault = useLayoutWithDefault; + } + + @Override + public Optional<Integer> get() { + return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0); + } + } +} diff --git a/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt b/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt new file mode 100644 index 00000000..1a58afcb --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt @@ -0,0 +1,46 @@ +package com.android.intentresolver.v2.data + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.UserHandle +import android.util.Log +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +private const val TAG = "BroadcastFlow" + +/** + * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new + * value whenever broadcast matching _filter_ is received. The result value will be computed using + * [transform] and emitted if non-null. + */ +internal fun <T> broadcastFlow( + context: Context, + filter: IntentFilter, + user: UserHandle, + transform: (Intent) -> T? +): Flow<T> = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + transform(intent)?.also { result -> + trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } + } + ?: Log.w(TAG, "Ignored broadcast $intent") + } + } + + context.registerReceiverAsUser( + receiver, + user, + IntentFilter(filter), + null, + null, + Context.RECEIVER_NOT_EXPORTED + ) + awaitClose { context.unregisterReceiver(receiver) } +} diff --git a/java/src/com/android/intentresolver/v2/data/model/User.kt b/java/src/com/android/intentresolver/v2/data/model/User.kt new file mode 100644 index 00000000..504b04c8 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/model/User.kt @@ -0,0 +1,50 @@ +package com.android.intentresolver.v2.data.model + +import android.annotation.UserIdInt +import android.os.UserHandle +import com.android.intentresolver.v2.data.model.User.Type +import com.android.intentresolver.v2.data.model.User.Type.FULL +import com.android.intentresolver.v2.data.model.User.Type.PROFILE + +/** + * A User represents the owner of a distinct set of content. + * * maps 1:1 to a UserHandle or UserId (Int) value. + * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the + * [type] property. + * + * See + * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) + * + * ``` + * val users = listOf( + * User(id = 0, role = PERSONAL), + * User(id = 10, role = WORK), + * User(id = 11, role = CLONE), + * User(id = 12, role = PRIVATE), + * ) + * ``` + */ +data class User( + @UserIdInt val id: Int, + val role: Role, +) { + val handle: UserHandle = UserHandle.of(id) + + val type: Type + get() = role.type + + enum class Type { + FULL, + PROFILE + } + + enum class Role( + /** The type of the role user. */ + val type: Type + ) { + PERSONAL(FULL), + PRIVATE(PROFILE), + WORK(PROFILE), + CLONE(PROFILE) + } +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt new file mode 100644 index 00000000..7debdf07 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.data.repository + +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY +import android.content.res.Resources +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DevicePolicyResources @Inject constructor( + @ApplicationOwned private val resources: Resources, + devicePolicyManager: DevicePolicyManager +) { + private val policyResources = devicePolicyManager.resources + + val personalTabLabel by lazy { + requireNotNull(policyResources.getString(RESOLVER_PERSONAL_TAB) { + resources.getString(R.string.resolver_personal_tab) + }) + } + + val workTabLabel by lazy { + requireNotNull(policyResources.getString(RESOLVER_WORK_TAB) { + resources.getString(R.string.resolver_work_tab) + }) + } + + val personalTabAccessibilityLabel by lazy { + requireNotNull(policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_personal_tab_accessibility) + }) + } + + val workTabAccessibilityLabel by lazy { + requireNotNull(policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_work_tab_accessibility) + }) + } + + fun getWorkProfileNotSupportedMessage(launcherName: String): String { + return requireNotNull(policyResources.getString(RESOLVER_WORK_PROFILE_NOT_SUPPORTED, { + resources.getString( + R.string.activity_resolver_work_profiles_support, + launcherName) + }, launcherName)) + } +}
\ No newline at end of file diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt new file mode 100644 index 00000000..fc82efee --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt @@ -0,0 +1,29 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.pm.UserInfo +import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.data.model.User.Role + +/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */ +fun UserInfo.getSupportedUserRole(): Role? = + when { + isFull -> Role.PERSONAL + isManagedProfile -> Role.WORK + isCloneProfile -> Role.CLONE + isPrivateProfile -> Role.PRIVATE + else -> null + } + +/** + * Creates a [User], based on values from a [UserInfo]. + * + * ``` + * val users: List<User> = + * getEnabledProfiles(user).map(::toUser).filterNotNull() + * ``` + * + * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null + */ +fun UserInfo.toUser(): User? { + return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt new file mode 100644 index 00000000..dc809b46 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -0,0 +1,261 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE +import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.Intent.ACTION_PROFILE_UNAVAILABLE +import android.content.Intent.EXTRA_QUIET_MODE +import android.content.Intent.EXTRA_USER +import android.content.IntentFilter +import android.content.pm.UserInfo +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.Main +import com.android.intentresolver.inject.ProfileParent +import com.android.intentresolver.v2.data.broadcastFlow +import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +interface UserRepository { + /** + * A [Flow] user profile groups. Each map contains the context user along with all members of + * the profile group. This includes the (Full) parent user, if the context user is a profile. + */ + val users: Flow<Map<UserHandle, User>> + + /** + * A [Flow] of availability. Only profile users may become unavailable. + * + * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. + */ + fun isAvailable(user: User): Flow<Boolean> + + /** + * Request that availability be updated to the requested state. This currently includes toggling + * quiet mode as needed. This may involve additional background actions, such as starting or + * stopping a profile user (along with their many associated processes). + * + * If successful, the change will be applied after the call returns and can be observed using + * [UserRepository.isAvailable] for the given user. + * + * No actions are taken if the user is already in requested state. + * + * @throws IllegalArgumentException if called for an unsupported user type + */ + suspend fun requestState(user: User, available: Boolean) +} + +private const val TAG = "UserRepository" + +private data class UserWithState(val user: User, val available: Boolean) + +private typealias UserStateMap = Map<UserHandle, UserWithState> + +/** Tracks and publishes state for the parent user and associated profiles. */ +class UserRepositoryImpl +@VisibleForTesting +constructor( + private val profileParent: UserHandle, + private val userManager: UserManager, + /** A flow of events which represent user-state changes from [UserManager]. */ + private val userEvents: Flow<UserEvent>, + scope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher +) : UserRepository { + @Inject + constructor( + @ApplicationContext context: Context, + @ProfileParent profileParent: UserHandle, + userManager: UserManager, + @Main scope: CoroutineScope, + @Background background: CoroutineDispatcher + ) : this( + profileParent, + userManager, + userEvents = userBroadcastFlow(context, profileParent), + scope, + background + ) + + data class UserEvent(val action: String, val user: UserHandle, val quietMode: Boolean = false) + + /** + * An exception which indicates that an inconsistency exists between the user state map and the + * rest of the system. + */ + internal class UserStateException( + override val message: String, + val event: UserEvent, + override val cause: Throwable? = null + ) : RuntimeException("$message: event=$event", cause) + + private val usersWithState: Flow<UserStateMap> = + userEvents + .onStart { emit(UserEvent(INITIALIZE, profileParent)) } + .onEach { Log.i("UserDataSource", "userEvent: $it") } + .runningFold<UserEvent, UserStateMap>(emptyMap()) { users, event -> + try { + // Handle an action by performing some operation, then returning a new map + when (event.action) { + INITIALIZE -> createNewUserStateMap(profileParent) + ACTION_PROFILE_ADDED -> handleProfileAdded(event, users) + ACTION_PROFILE_REMOVED -> handleProfileRemoved(event, users) + ACTION_MANAGED_PROFILE_UNAVAILABLE, + ACTION_MANAGED_PROFILE_AVAILABLE, + ACTION_PROFILE_AVAILABLE, + ACTION_PROFILE_UNAVAILABLE -> handleAvailability(event, users) + else -> { + Log.w(TAG, "Unhandled event: $event)") + users + } + } + } catch (e: UserStateException) { + Log.e(TAG, "An error occurred handling an event: ${e.event}", e) + Log.e(TAG, "Attempting to recover...") + createNewUserStateMap(profileParent) + } + } + .onEach { Log.i("UserDataSource", "userStateMap: $it") } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + .filterNot { it.isEmpty() } + + override val users: Flow<Map<UserHandle, User>> = + usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged() + + private val availability: Flow<Map<UserHandle, Boolean>> = + usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() + + override fun isAvailable(user: User): Flow<Boolean> { + return isAvailable(user.handle) + } + + @VisibleForTesting + fun isAvailable(handle: UserHandle): Flow<Boolean> { + return availability.map { it[handle] ?: false } + } + + override suspend fun requestState(user: User, available: Boolean) { + require(user.type == User.Type.PROFILE) { "Only profile users are supported" } + return requestState(user.handle, available) + } + + @VisibleForTesting + suspend fun requestState(user: UserHandle, available: Boolean) { + return withContext(backgroundDispatcher) { + Log.i(TAG, "requestQuietModeEnabled: ${!available} for user $user") + userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user) + } + } + + private fun handleAvailability(event: UserEvent, current: UserStateMap): UserStateMap { + val userEntry = + current[event.user] + ?: throw UserStateException("User was not present in the map", event) + return current + (event.user to userEntry.copy(available = !event.quietMode)) + } + + private fun handleProfileRemoved(event: UserEvent, current: UserStateMap): UserStateMap { + if (!current.containsKey(event.user)) { + throw UserStateException("User was not present in the map", event) + } + return current.filterKeys { it != event.user } + } + + private suspend fun handleProfileAdded(event: UserEvent, current: UserStateMap): UserStateMap { + val user = + try { + requireNotNull(readUser(event.user)) + } catch (e: Exception) { + throw UserStateException("Failed to read user from UserManager", event, e) + } + return current + (event.user to UserWithState(user, !event.quietMode)) + } + + private suspend fun createNewUserStateMap(user: UserHandle): UserStateMap { + val profiles = readProfileGroup(user) + return profiles + .mapNotNull { userInfo -> + userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } + } + .associateBy { it.user.handle } + } + + private suspend fun readProfileGroup(handle: UserHandle): List<UserInfo> { + return withContext(backgroundDispatcher) { + @Suppress("DEPRECATION") userManager.getEnabledProfiles(handle.identifier) + } + .toList() + } + + /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ + private suspend fun readUser(user: UserHandle): User? { + val userInfo = + withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } + return userInfo?.let { info -> + info.getSupportedUserRole()?.let { role -> User(info.id, role) } + } + } +} + +/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */ +private fun Intent.toUserEvent(): UserEvent? { + val action = action + val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java) + val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) ?: false + return if (user == null || action == null) { + null + } else { + UserEvent(action, user, quietMode) + } +} + +const val INITIALIZE = "INITIALIZE" + +private fun createFilter(actions: Iterable<String>): IntentFilter { + return IntentFilter().apply { actions.forEach(::addAction) } +} + +private fun UserInfo?.isAvailable(): Boolean { + return this?.isQuietModeEnabled != true +} + +private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow<UserEvent> { + val userActions = + setOf( + ACTION_PROFILE_ADDED, + ACTION_PROFILE_REMOVED, + + // Quiet mode enabled/disabled for managed + // From: UserController.broadcastProfileAvailabilityChanges + // In response to setQuietModeEnabled + ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only + ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only + + // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile + // true' + ACTION_PROFILE_AVAILABLE, // quiet mode, + ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type + ) + return broadcastFlow(context, createFilter(userActions), profileParent, Intent::toUserEvent) +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt new file mode 100644 index 00000000..94f985e7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt @@ -0,0 +1,34 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.Context +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.inject.ApplicationUser +import com.android.intentresolver.inject.ProfileParent +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface UserRepositoryModule { + companion object { + @Provides + @Singleton + @ApplicationUser + fun applicationUser(@ApplicationContext context: Context): UserHandle = context.user + + @Provides + @Singleton + @ProfileParent + fun profileParent(@ApplicationUser user: UserHandle, userManager: UserManager): UserHandle { + return userManager.getProfileParent(user) ?: user + } + } + + @Binds @Singleton fun userRepository(impl: UserRepositoryImpl): UserRepository +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt new file mode 100644 index 00000000..7ee78d91 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt @@ -0,0 +1,46 @@ +package com.android.intentresolver.v2.data.repository + +import android.content.Context +import androidx.core.content.getSystemService +import com.android.intentresolver.v2.data.model.User + +/** + * Provides cached instances of a [system service][Context.getSystemService] created with + * [the context of a specified user][Context.createContextAsUser]. + * + * System services which have only `@UserHandleAware` APIs operate on the user id available from + * [Context.getUser], the context used to retrieve the service. This utility helps adapt a per-user + * API model to work in multi-user manner. + * + * Example usage: + * ``` + * val usageStats = userScopedService<UsageStatsManager>(context) + * + * fun getStatsForUser( + * user: User, + * from: Long, + * to: Long + * ): UsageStats { + * return usageStats.forUser(user) + * .queryUsageStats(INTERVAL_BEST, from, to) + * } + * ``` + */ +interface UserScopedService<T> { + fun forUser(user: User): T +} + +inline fun <reified T> userScopedService(context: Context): UserScopedService<T> { + return object : UserScopedService<T> { + private val map = mutableMapOf<User, T>() + + override fun forUser(user: User): T { + return synchronized(this) { + map.getOrPut(user) { + val userContext = context.createContextAsUser(user.handle, 0) + requireNotNull(userContext.getSystemService()) + } + } + } + } +} diff --git a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java new file mode 100644 index 00000000..2f1e1b59 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.emptystate; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import com.android.intentresolver.emptystate.EmptyState; +import com.android.internal.annotations.VisibleForTesting; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by + * some empty-state status. + */ +public class EmptyStateUiHelper { + private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier; + private final View mEmptyStateView; + private final View mListView; + private final View mEmptyStateContainerView; + private final TextView mEmptyStateTitleView; + private final TextView mEmptyStateSubtitleView; + private final Button mEmptyStateButtonView; + private final View mEmptyStateProgressView; + private final View mEmptyStateEmptyView; + + public EmptyStateUiHelper( + ViewGroup rootView, + int listViewResourceId, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; + mEmptyStateView = + rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); + mListView = rootView.requireViewById(listViewResourceId); + mEmptyStateContainerView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_container); + mEmptyStateTitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_title); + mEmptyStateSubtitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_subtitle); + mEmptyStateButtonView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_button); + mEmptyStateProgressView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_progress); + mEmptyStateEmptyView = mEmptyStateView.requireViewById(com.android.internal.R.id.empty); + } + + /** + * Display the described empty state. + * @param emptyState the data describing the cause of this empty-state condition. + * @param buttonOnClick handler for a button that the user might be able to use to circumvent + * the empty-state condition. If null, no button will be displayed. + */ + public void showEmptyState(EmptyState emptyState, View.OnClickListener buttonOnClick) { + resetViewVisibilities(); + setupContainerPadding(); + + String title = emptyState.getTitle(); + if (title != null) { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateTitleView.setText(title); + } else { + mEmptyStateTitleView.setVisibility(View.GONE); + } + + String subtitle = emptyState.getSubtitle(); + if (subtitle != null) { + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setText(subtitle); + } else { + mEmptyStateSubtitleView.setVisibility(View.GONE); + } + + mEmptyStateEmptyView.setVisibility( + emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); + // TODO: The EmptyState API says that if `useDefaultEmptyView()` is true, we'll ignore the + // state's specified title/subtitle; where (if anywhere) is that implemented? + + mEmptyStateButtonView.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); + mEmptyStateButtonView.setOnClickListener(buttonOnClick); + + // Don't show the main list view when we're showing an empty state. + mListView.setVisibility(View.GONE); + } + + /** Sets up the padding of the view containing the empty state screens. */ + public void setupContainerPadding() { + Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); + bottomPaddingOverride.ifPresent(paddingBottom -> + mEmptyStateContainerView.setPadding( + mEmptyStateContainerView.getPaddingLeft(), + mEmptyStateContainerView.getPaddingTop(), + mEmptyStateContainerView.getPaddingRight(), + paddingBottom)); + } + + public void showSpinner() { + mEmptyStateTitleView.setVisibility(View.INVISIBLE); + // TODO: subtitle? + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.VISIBLE); + mEmptyStateEmptyView.setVisibility(View.GONE); + } + + public void hide() { + mEmptyStateView.setVisibility(View.GONE); + mListView.setVisibility(View.VISIBLE); + } + + // TODO: this is exposed for testing so we can thoroughly prepare initial conditions that let us + // observe the resulting change. In reality it's only invoked as part of `showEmptyState()` and + // we could consider setting up narrower "realistic" preconditions to make assertions about the + // higher-level operation. + @VisibleForTesting + void resetViewVisibilities() { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.GONE); + mEmptyStateEmptyView.setVisibility(View.GONE); + mEmptyStateView.setVisibility(View.VISIBLE); + } +} + diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java new file mode 100644 index 00000000..e9d1bb34 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -0,0 +1,157 @@ +/* + * 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.emptystate; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; + +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.content.pm.ResolveInfo; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.internal.R; + +import java.util.List; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * there are no apps available. + */ +public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { + + @NonNull + private final Context mContext; + @Nullable + private final UserHandle mWorkProfileUserHandle; + @Nullable + private final UserHandle mPersonalProfileUserHandle; + @NonNull + private final String mMetricsCategory; + @NonNull + private final UserHandle mTabOwnerUserHandleForLaunch; + + public NoAppsAvailableEmptyStateProvider(@NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @Nullable UserHandle personalProfileUserHandle, @NonNull String metricsCategory, + @NonNull UserHandle tabOwnerUserHandleForLaunch) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mPersonalProfileUserHandle = personalProfileUserHandle; + mMetricsCategory = metricsCategory; + mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + } + + @Nullable + @Override + @SuppressWarnings("ReferenceEquality") + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + UserHandle listUserHandle = resolverListAdapter.getUserHandle(); + + if (mWorkProfileUserHandle != null + && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) + || !hasAppsInOtherProfile(resolverListAdapter))) { + + String title; + if (listUserHandle == mPersonalProfileUserHandle) { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_PERSONAL_APPS, + () -> mContext.getString(R.string.resolver_no_personal_apps_available)); + } else { + title = mContext.getSystemService( + DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_WORK_APPS, + () -> mContext.getString(R.string.resolver_no_work_apps_available)); + } + + return new NoAppsAvailableEmptyState( + title, mMetricsCategory, + /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle + ); + } else if (mWorkProfileUserHandle == null) { + // Return default empty state without tracking + return new DefaultEmptyState(); + } + + return null; + } + + private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { + if (mWorkProfileUserHandle == null) { + return false; + } + List<ResolvedComponentInfo> resolversForIntent = + adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); + for (ResolvedComponentInfo info : resolversForIntent) { + ResolveInfo resolveInfo = info.getResolveInfoAt(0); + if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { + return true; + } + } + return false; + } + + public static class DefaultEmptyState implements EmptyState { + @Override + public boolean useDefaultEmptyView() { + return true; + } + } + + public static class NoAppsAvailableEmptyState implements EmptyState { + + @NonNull + private final String mTitle; + + @NonNull + private final String mMetricsCategory; + + private final boolean mIsPersonalProfile; + + public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory, + boolean isPersonalProfile) { + mTitle = title; + mMetricsCategory = metricsCategory; + mIsPersonalProfile = isPersonalProfile; + } + + @NonNull + @Override + public String getTitle() { + return mTitle; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) + .setStrings(mMetricsCategory) + .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) + .write(); + } + } +} diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java new file mode 100644 index 00000000..b744c589 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java @@ -0,0 +1,138 @@ +/* + * 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.emptystate; + +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; + +/** + * Empty state provider that does not allow cross profile sharing, it will return a blocker + * in case if the profile of the current tab is not the same as the profile of the calling app. + */ +public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mPersonalProfileUserHandle; + private final EmptyState mNoWorkToPersonalEmptyState; + private final EmptyState mNoPersonalToWorkEmptyState; + private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; + private final UserHandle mTabOwnerUserHandleForLaunch; + + public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, + EmptyState noWorkToPersonalEmptyState, + EmptyState noPersonalToWorkEmptyState, + CrossProfileIntentsChecker crossProfileIntentsChecker, + UserHandle tabOwnerUserHandleForLaunch) { + mPersonalProfileUserHandle = personalUserHandle; + mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; + mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; + mCrossProfileIntentsChecker = crossProfileIntentsChecker; + mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + boolean shouldShowBlocker = + !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) + && !mCrossProfileIntentsChecker + .hasCrossProfileIntents(resolverListAdapter.getIntents(), + mTabOwnerUserHandleForLaunch.getIdentifier(), + resolverListAdapter.getUserHandle().getIdentifier()); + + if (!shouldShowBlocker) { + return null; + } + + if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { + return mNoWorkToPersonalEmptyState; + } else { + return mNoPersonalToWorkEmptyState; + } + } + + + /** + * Empty state that gets strings from the device policy manager and tracks events into + * event logger of the device policy events. + */ + public static class DevicePolicyBlockerEmptyState implements EmptyState { + + @NonNull + private final Context mContext; + private final String mDevicePolicyStringTitleId; + @StringRes + private final int mDefaultTitleResource; + private final String mDevicePolicyStringSubtitleId; + @StringRes + private final int mDefaultSubtitleResource; + private final int mEventId; + @NonNull + private final String mEventCategory; + + public DevicePolicyBlockerEmptyState(@NonNull Context context, + String devicePolicyStringTitleId, @StringRes int defaultTitleResource, + String devicePolicyStringSubtitleId, @StringRes int defaultSubtitleResource, + int devicePolicyEventId, @NonNull String devicePolicyEventCategory) { + mContext = context; + mDevicePolicyStringTitleId = devicePolicyStringTitleId; + mDefaultTitleResource = defaultTitleResource; + mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId; + mDefaultSubtitleResource = defaultSubtitleResource; + mEventId = devicePolicyEventId; + mEventCategory = devicePolicyEventCategory; + } + + @Nullable + @Override + public String getTitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringTitleId, + () -> mContext.getString(mDefaultTitleResource)); + } + + @Nullable + @Override + public String getSubtitle() { + return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( + mDevicePolicyStringSubtitleId, + () -> mContext.getString(mDefaultSubtitleResource)); + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent(mEventId) + .setStrings(mEventCategory) + .write(); + } + + @Override + public boolean shouldSkipDataRebuild() { + return true; + } + } +} diff --git a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java new file mode 100644 index 00000000..a6fee3ec --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -0,0 +1,116 @@ +/* + * 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.emptystate; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; + +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * work profile is paused and we need to show a button to enable it. + */ +public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { + + private final UserHandle mWorkProfileUserHandle; + private final WorkProfileAvailabilityManager mWorkProfileAvailability; + private final String mMetricsCategory; + private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private final Context mContext; + + public WorkProfilePausedEmptyStateProvider(@NonNull Context context, + @Nullable UserHandle workProfileUserHandle, + @NonNull WorkProfileAvailabilityManager workProfileAvailability, + @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, + @NonNull String metricsCategory) { + mContext = context; + mWorkProfileUserHandle = workProfileUserHandle; + mWorkProfileAvailability = workProfileAvailability; + mMetricsCategory = metricsCategory; + mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) + || !mWorkProfileAvailability.isQuietModeEnabled() + || resolverListAdapter.getCount() == 0) { + return null; + } + + final String title = mContext.getSystemService(DevicePolicyManager.class) + .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, + () -> mContext.getString(R.string.resolver_turn_on_work_apps)); + + return new WorkProfileOffEmptyState(title, (tab) -> { + tab.showSpinner(); + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + mWorkProfileAvailability.requestQuietModeEnabled(false); + }, mMetricsCategory); + } + + public static class WorkProfileOffEmptyState implements EmptyState { + + private final String mTitle; + private final ClickListener mOnClick; + private final String mMetricsCategory; + + public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, + @NonNull String metricsCategory) { + mTitle = title; + mOnClick = onClick; + mMetricsCategory = metricsCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public ClickListener getButtonClickListener() { + return mOnClick; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) + .setStrings(mMetricsCategory) + .write(); + } + } +} diff --git a/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt new file mode 100644 index 00000000..4e8783f8 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.icons + +import android.content.Context +import androidx.lifecycle.Lifecycle +import com.android.intentresolver.icons.DefaultTargetDataLoader +import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.inject.ActivityOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +object TargetDataLoaderModule { + @Provides + @ActivityScoped + fun targetDataLoader( + @ActivityContext context: Context, + @ActivityOwned lifecycle: Lifecycle, + ): TargetDataLoader = DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false) +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt b/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt new file mode 100644 index 00000000..5855e2fc --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import com.android.intentresolver.ChooserRequestParameters + +/** A class that is able to identify components that should be hidden from the user. */ +interface FilterableComponents { + /** Whether this component should hidden from the user. */ + fun isComponentFiltered(name: ComponentName): Boolean +} + +/** A class that never filters components. */ +class NoComponentFiltering : FilterableComponents { + override fun isComponentFiltered(name: ComponentName): Boolean = false +} + +/** A class that filters components by chooser request filter. */ +class ChooserRequestFilteredComponents( + private val chooserRequestParameters: ChooserRequestParameters, +) : FilterableComponents { + override fun isComponentFiltered(name: ComponentName): Boolean = + chooserRequestParameters.filteredComponentNames.contains(name) +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt b/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt new file mode 100644 index 00000000..bb9394b4 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt @@ -0,0 +1,70 @@ +package com.android.intentresolver.v2.listcontroller + +import android.content.Intent +import android.content.pm.PackageManager +import android.os.UserHandle +import com.android.intentresolver.ResolvedComponentInfo + +/** A class for translating [Intent]s to [ResolvedComponentInfo]s. */ +interface IntentResolver { + /** + * Get data about all the ways the user with the specified handle can resolve any of the + * provided `intents`. + */ + fun getResolversForIntentAsUser( + shouldGetResolvedFilter: Boolean, + shouldGetActivityMetadata: Boolean, + shouldGetOnlyDefaultActivities: Boolean, + intents: List<Intent>, + userHandle: UserHandle, + ): List<ResolvedComponentInfo> +} + +/** Resolves [Intent]s using the [packageManager], deduping using the given [ResolveListDeduper]. */ +class IntentResolverImpl( + private val packageManager: PackageManager, + resolveListDeduper: ResolveListDeduper, +) : IntentResolver, ResolveListDeduper by resolveListDeduper { + override fun getResolversForIntentAsUser( + shouldGetResolvedFilter: Boolean, + shouldGetActivityMetadata: Boolean, + shouldGetOnlyDefaultActivities: Boolean, + intents: List<Intent>, + userHandle: UserHandle, + ): List<ResolvedComponentInfo> { + val baseFlags = + ((if (shouldGetOnlyDefaultActivities) PackageManager.MATCH_DEFAULT_ONLY else 0) or + PackageManager.MATCH_DIRECT_BOOT_AWARE or + PackageManager.MATCH_DIRECT_BOOT_UNAWARE or + (if (shouldGetResolvedFilter) PackageManager.GET_RESOLVED_FILTER else 0) or + (if (shouldGetActivityMetadata) PackageManager.GET_META_DATA else 0) or + PackageManager.MATCH_CLONE_PROFILE) + return getResolversForIntentAsUserInternal( + intents, + userHandle, + baseFlags, + ) + } + + private fun getResolversForIntentAsUserInternal( + intents: List<Intent>, + userHandle: UserHandle, + baseFlags: Int, + ): List<ResolvedComponentInfo> = buildList { + for (intent in intents) { + var flags = baseFlags + if (intent.isWebIntent || intent.flags and Intent.FLAG_ACTIVITY_MATCH_EXTERNAL != 0) { + flags = flags or PackageManager.MATCH_INSTANT + } + // Because of AIDL bug, queryIntentActivitiesAsUser can't accept subclasses of Intent. + val fixedIntent = + if (intent.javaClass != Intent::class.java) { + Intent(intent) + } else { + intent + } + val infos = packageManager.queryIntentActivitiesAsUser(fixedIntent, flags, userHandle) + addToResolveListWithDedupe(this, fixedIntent, infos) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt b/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt new file mode 100644 index 00000000..b2856526 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.app.AppGlobals +import android.content.ContentResolver +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.IPackageManager +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.RemoteException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** Class that stores and retrieves the most recently chosen resolutions. */ +interface LastChosenManager { + + /** Returns the most recently chosen resolution. */ + suspend fun getLastChosen(): ResolveInfo + + /** Sets the most recently chosen resolution. */ + suspend fun setLastChosen(intent: Intent, filter: IntentFilter, match: Int) +} + +/** + * Stores and retrieves the most recently chosen resolutions using the [PackageManager] provided by + * the [packageManagerProvider]. + */ +class PackageManagerLastChosenManager( + private val contentResolver: ContentResolver, + private val bgDispatcher: CoroutineDispatcher, + private val targetIntent: Intent, + private val packageManagerProvider: () -> IPackageManager = AppGlobals::getPackageManager, +) : LastChosenManager { + + @Throws(RemoteException::class) + override suspend fun getLastChosen(): ResolveInfo { + return withContext(bgDispatcher) { + packageManagerProvider() + .getLastChosenActivity( + targetIntent, + targetIntent.resolveTypeIfNeeded(contentResolver), + PackageManager.MATCH_DEFAULT_ONLY, + ) + } + } + + @Throws(RemoteException::class) + override suspend fun setLastChosen(intent: Intent, filter: IntentFilter, match: Int) { + return withContext(bgDispatcher) { + packageManagerProvider() + .setLastChosenActivity( + intent, + intent.resolveType(contentResolver), + PackageManager.MATCH_DEFAULT_ONLY, + filter, + match, + intent.component, + ) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt b/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt new file mode 100644 index 00000000..4ddab755 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +/** Controller for managing lists of [com.android.intentresolver.ResolvedComponentInfo]s. */ +interface ListController : + LastChosenManager, IntentResolver, ResolvedComponentFiltering, ResolvedComponentSorting diff --git a/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt b/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt new file mode 100644 index 00000000..cae2af95 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt @@ -0,0 +1,34 @@ +package com.android.intentresolver.v2.listcontroller + +import android.app.ActivityManager +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** Class for checking if a permission has been granted. */ +interface PermissionChecker { + /** Checks if the given [permission] has been granted. */ + suspend fun checkComponentPermission( + permission: String, + uid: Int, + owningUid: Int, + exported: Boolean, + ): Int +} + +/** + * Class for checking if a permission has been granted using the static + * [ActivityManager.checkComponentPermission]. + */ +class ActivityManagerPermissionChecker( + private val bgDispatcher: CoroutineDispatcher, +) : PermissionChecker { + override suspend fun checkComponentPermission( + permission: String, + uid: Int, + owningUid: Int, + exported: Boolean, + ): Int = + withContext(bgDispatcher) { + ActivityManager.checkComponentPermission(permission, uid, owningUid, exported) + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt b/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt new file mode 100644 index 00000000..8be45ba2 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.SharedPreferences + +/** A class that is able to identify components that should be pinned for the user. */ +interface PinnableComponents { + /** Whether this component is pinned by the user. */ + fun isComponentPinned(name: ComponentName): Boolean +} + +/** A class that never pins components. */ +class NoComponentPinning : PinnableComponents { + override fun isComponentPinned(name: ComponentName): Boolean = false +} + +/** A class that determines pinnable components by user preferences. */ +class SharedPreferencesPinnedComponents( + private val pinnedSharedPreferences: SharedPreferences, +) : PinnableComponents { + override fun isComponentPinned(name: ComponentName): Boolean = + pinnedSharedPreferences.getBoolean(name.flattenToString(), false) +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt new file mode 100644 index 00000000..f0b4bf3f --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt @@ -0,0 +1,69 @@ +package com.android.intentresolver.v2.listcontroller + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ResolveInfo +import android.util.Log +import com.android.intentresolver.ResolvedComponentInfo + +/** A class for adding [ResolveInfo]s to a list of [ResolvedComponentInfo]s without duplicates. */ +interface ResolveListDeduper { + /** + * Adds [ResolveInfo]s in [from] to [ResolvedComponentInfo]s in [into], creating new + * [ResolvedComponentInfo]s when there is not already a corresponding one. + * + * This method may be destructive to both the given [into] list and the underlying + * [ResolvedComponentInfo]s. + */ + fun addToResolveListWithDedupe( + into: MutableList<ResolvedComponentInfo>, + intent: Intent, + from: List<ResolveInfo>, + ) +} + +/** + * Default implementation for adding [ResolveInfo]s to a list of [ResolvedComponentInfo]s without + * duplicates. Uses the given [PinnableComponents] to determine the pinning state of newly created + * [ResolvedComponentInfo]s. + */ +class ResolveListDeduperImpl(pinnableComponents: PinnableComponents) : + ResolveListDeduper, PinnableComponents by pinnableComponents { + override fun addToResolveListWithDedupe( + into: MutableList<ResolvedComponentInfo>, + intent: Intent, + from: List<ResolveInfo>, + ) { + from.forEach { newInfo -> + if (newInfo.userHandle == null) { + Log.w(TAG, "Skipping ResolveInfo with no userHandle: $newInfo") + return@forEach + } + val oldInfo = into.firstOrNull { isSameResolvedComponent(newInfo, it) } + // If existing resolution found, add to existing and filter out + if (oldInfo != null) { + oldInfo.add(intent, newInfo) + } else { + with(newInfo.activityInfo) { + into.add( + ResolvedComponentInfo( + ComponentName(packageName, name), + intent, + newInfo, + ) + .apply { isPinned = isComponentPinned(name) }, + ) + } + } + } + } + + private fun isSameResolvedComponent(a: ResolveInfo, b: ResolvedComponentInfo): Boolean { + val ai = a.activityInfo + return ai.packageName == b.name.packageName && ai.name == b.name.className + } + + companion object { + const val TAG = "ResolveListDeduper" + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt new file mode 100644 index 00000000..e78bff00 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt @@ -0,0 +1,121 @@ +package com.android.intentresolver.v2.listcontroller + +import android.content.pm.PackageManager +import android.util.Log +import com.android.intentresolver.ResolvedComponentInfo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +/** Provides filtering methods for lists of [ResolvedComponentInfo]. */ +interface ResolvedComponentFiltering { + /** + * Returns a list with all the [ResolvedComponentInfo] in [inputList], less the ones that are + * not eligible. + */ + suspend fun filterIneligibleActivities( + inputList: List<ResolvedComponentInfo>, + ): List<ResolvedComponentInfo> + + /** Filter out any low priority items. */ + fun filterLowPriority(inputList: List<ResolvedComponentInfo>): List<ResolvedComponentInfo> +} + +/** + * Default instantiation of the filtering methods for lists of [ResolvedComponentInfo]. + * + * Binder calls are performed on the given [bgDispatcher] and permissions are checked as if launched + * from the given [launchedFromUid] UID. Component filtering is handled by the given + * [FilterableComponents] and permission checking is handled by the given [PermissionChecker]. + */ +class ResolvedComponentFilteringImpl( + private val launchedFromUid: Int, + filterableComponents: FilterableComponents, + permissionChecker: PermissionChecker, +) : + ResolvedComponentFiltering, + PermissionChecker by permissionChecker, + FilterableComponents by filterableComponents { + constructor( + bgDispatcher: CoroutineDispatcher, + launchedFromUid: Int, + filterableComponents: FilterableComponents, + ) : this( + launchedFromUid = launchedFromUid, + filterableComponents = filterableComponents, + permissionChecker = ActivityManagerPermissionChecker(bgDispatcher), + ) + + /** + * Filter out items that are filtered by [FilterableComponents] or do not have the necessary + * permissions. + */ + override suspend fun filterIneligibleActivities( + inputList: List<ResolvedComponentInfo>, + ): List<ResolvedComponentInfo> = coroutineScope { + inputList + .map { + val activityInfo = it.getResolveInfoAt(0).activityInfo + if (isComponentFiltered(activityInfo.componentName)) { + CompletableDeferred(value = null) + } else { + // Do all permission checks in parallel + async { + val granted = + checkComponentPermission( + activityInfo.permission, + launchedFromUid, + activityInfo.applicationInfo.uid, + activityInfo.exported, + ) == PackageManager.PERMISSION_GRANTED + if (granted) it else null + } + } + } + .awaitAll() + .filterNotNull() + } + + /** + * Filters out all elements starting with the first elements with a different priority or + * default status than the first element. + */ + override fun filterLowPriority( + inputList: List<ResolvedComponentInfo>, + ): List<ResolvedComponentInfo> { + val firstResolveInfo = inputList[0].getResolveInfoAt(0) + // Only display the first matches that are either of equal + // priority or have asked to be default options. + val firstDiffIndex = + inputList.indexOfFirst { resolvedComponentInfo -> + val resolveInfo = resolvedComponentInfo.getResolveInfoAt(0) + if (firstResolveInfo == resolveInfo) { + false + } else { + if (DEBUG) { + Log.v( + TAG, + "${firstResolveInfo?.activityInfo?.name}=" + + "${firstResolveInfo?.priority}/${firstResolveInfo?.isDefault}" + + " vs ${resolveInfo?.activityInfo?.name}=" + + "${resolveInfo?.priority}/${resolveInfo?.isDefault}" + ) + } + firstResolveInfo!!.priority != resolveInfo!!.priority || + firstResolveInfo.isDefault != resolveInfo.isDefault + } + } + return if (firstDiffIndex == -1) { + inputList + } else { + inputList.subList(0, firstDiffIndex) + } + } + + companion object { + private const val TAG = "ResolvedComponentFilter" + private const val DEBUG = false + } +} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt new file mode 100644 index 00000000..8ab41ef0 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt @@ -0,0 +1,108 @@ +package com.android.intentresolver.v2.listcontroller + +import android.os.UserHandle +import android.util.Log +import com.android.intentresolver.ResolvedComponentInfo +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.chooser.TargetInfo +import com.android.intentresolver.model.AbstractResolverComparator +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** Provides sorting methods for lists of [ResolvedComponentInfo]. */ +interface ResolvedComponentSorting { + /** Returns the a copy of the [inputList] sorted by app share score. */ + suspend fun sorted(inputList: List<ResolvedComponentInfo>?): List<ResolvedComponentInfo>? + + /** Returns the app share score of the [target]. */ + fun getScore(target: DisplayResolveInfo): Float + + /** Returns the app share score of the [targetInfo]. */ + fun getScore(targetInfo: TargetInfo): Float + + /** Updates the model about [targetInfo]. */ + suspend fun updateModel(targetInfo: TargetInfo) + + /** Updates the model about Activity selection. */ + suspend fun updateChooserCounts(packageName: String, user: UserHandle, action: String) + + /** Cleans up resources. Nothing should be called after calling this. */ + fun destroy() +} + +/** + * Provides sorting methods using the given [resolverComparator]. + * + * Long calculations and binder calls are performed on the given [bgDispatcher]. + */ +class ResolvedComponentSortingImpl( + private val bgDispatcher: CoroutineDispatcher, + private val resolverComparator: AbstractResolverComparator, +) : ResolvedComponentSorting { + + private val computeComplete = AtomicReference<CompletableDeferred<Unit>?>(null) + + @Throws(InterruptedException::class) + private suspend fun computeIfNeeded(inputList: List<ResolvedComponentInfo>) { + if (computeComplete.compareAndSet(null, CompletableDeferred())) { + resolverComparator.setCallBack { computeComplete.get()!!.complete(Unit) } + resolverComparator.compute(inputList) + } + with(computeComplete.get()!!) { if (isCompleted) return else return await() } + } + + override suspend fun sorted( + inputList: List<ResolvedComponentInfo>?, + ): List<ResolvedComponentInfo>? { + if (inputList.isNullOrEmpty()) return inputList + + return withContext(bgDispatcher) { + try { + val beforeRank = System.currentTimeMillis() + computeIfNeeded(inputList) + val sorted = inputList.sortedWith(resolverComparator) + val afterRank = System.currentTimeMillis() + if (DEBUG) { + Log.d(TAG, "Time Cost: ${afterRank - beforeRank}") + } + sorted + } catch (e: InterruptedException) { + Log.e(TAG, "Compute & Sort was interrupted: $e") + null + } + } + } + + override fun getScore(target: DisplayResolveInfo): Float { + return resolverComparator.getScore(target) + } + + override fun getScore(targetInfo: TargetInfo): Float { + return resolverComparator.getScore(targetInfo) + } + + override suspend fun updateModel(targetInfo: TargetInfo) { + withContext(bgDispatcher) { resolverComparator.updateModel(targetInfo) } + } + + override suspend fun updateChooserCounts( + packageName: String, + user: UserHandle, + action: String, + ) { + withContext(bgDispatcher) { + resolverComparator.updateChooserCounts(packageName, user, action) + } + } + + override fun destroy() { + resolverComparator.destroy() + } + + companion object { + private const val TAG = "ResolvedComponentSort" + private const val DEBUG = false + } +} diff --git a/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt b/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt new file mode 100644 index 00000000..efbf053e --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt @@ -0,0 +1,35 @@ +package com.android.intentresolver.v2.platform + +import android.content.ComponentName +import android.content.res.Resources +import androidx.annotation.StringRes +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +internal fun Resources.componentName(@StringRes resId: Int): ComponentName? { + check(getResourceTypeName(resId) == "string") { "resId must be a string" } + return ComponentName.unflattenFromString(getString(resId)) +} + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ImageEditor + +@Module +@InstallIn(SingletonComponent::class) +object ImageEditorModule { + /** + * The name of the preferred Activity to launch for editing images. This is added to Intents to + * edit images using Intent.ACTION_EDIT. + */ + @Provides + @Singleton + @ImageEditor + fun imageEditorComponent(@ApplicationOwned resources: Resources) = + Optional.ofNullable(resources.componentName(R.string.config_systemImageEditor)) +} diff --git a/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt new file mode 100644 index 00000000..25ee9198 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt @@ -0,0 +1,32 @@ +package com.android.intentresolver.v2.platform + +import android.content.ComponentName +import android.content.res.Resources +import android.provider.Settings.Secure.NEARBY_SHARING_COMPONENT +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class NearbyShare + +@Module +@InstallIn(SingletonComponent::class) +object NearbyShareModule { + + @Provides + @Singleton + @NearbyShare + fun nearbyShareComponent(@ApplicationOwned resources: Resources, settings: SecureSettings) = + Optional.ofNullable( + ComponentName.unflattenFromString( + settings.getString(NEARBY_SHARING_COMPONENT)?.ifEmpty { null } + ?: resources.getString(R.string.config_defaultNearbySharingComponent), + ) + ) +} diff --git a/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt new file mode 100644 index 00000000..531152ba --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt @@ -0,0 +1,30 @@ +package com.android.intentresolver.v2.platform + +import android.content.ContentResolver +import android.provider.Settings +import javax.inject.Inject + +/** + * Implements [SecureSettings] backed by Settings.Secure and a ContentResolver. + * + * These methods make Binder calls and may block, so use on the Main thread should be avoided. + */ +class PlatformSecureSettings @Inject constructor(private val resolver: ContentResolver) : + SecureSettings { + + override fun getString(name: String): String? { + return Settings.Secure.getString(resolver, name) + } + + override fun getInt(name: String): Int? { + return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull() + } + + override fun getLong(name: String): Long? { + return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull() + } + + override fun getFloat(name: String): Float? { + return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull() + } +} diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt b/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt new file mode 100644 index 00000000..62ee8ae9 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt @@ -0,0 +1,25 @@ +package com.android.intentresolver.v2.platform + +import android.provider.Settings.SettingNotFoundException + +/** + * A component which provides access to values from [android.provider.Settings.Secure]. + * + * All methods return nullable types instead of throwing [SettingNotFoundException] which yields + * cleaner, more idiomatic Kotlin code: + * + * // apply a default: val foo = settings.getInt(FOO) ?: DEFAULT_FOO + * + * // assert if missing: val required = settings.getInt(REQUIRED_VALUE) ?: error("required value + * missing") + */ +interface SecureSettings { + + fun getString(name: String): String? + + fun getInt(name: String): Int? + + fun getLong(name: String): Long? + + fun getFloat(name: String): Float? +} diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt new file mode 100644 index 00000000..18f47023 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt @@ -0,0 +1,14 @@ +package com.android.intentresolver.v2.platform + +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SecureSettingsModule { + + @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings +} diff --git a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java new file mode 100644 index 00000000..271c6f38 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.ui; + +import android.content.Intent; +import android.provider.MediaStore; + +import androidx.annotation.StringRes; + +import com.android.intentresolver.R; +import com.android.intentresolver.v2.ResolverActivity; + +/** + * Provides a set of related resources for different use cases. + */ +public 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; + } +} diff --git a/java/src/com/android/intentresolver/v2/util/MutableLazy.kt b/java/src/com/android/intentresolver/v2/util/MutableLazy.kt new file mode 100644 index 00000000..4ce9b7fd --- /dev/null +++ b/java/src/com/android/intentresolver/v2/util/MutableLazy.kt @@ -0,0 +1,36 @@ +package com.android.intentresolver.v2.util + +import java.util.concurrent.atomic.AtomicReference +import kotlin.reflect.KProperty + +/** A lazy delegate that can be changed to a new lazy or null at any time. */ +class MutableLazy<T>(initializer: () -> T?) : Lazy<T?> { + + override val value: T? + get() = lazy.get()?.value + + private var lazy: AtomicReference<Lazy<T?>?> = AtomicReference(lazy(initializer)) + + override fun isInitialized(): Boolean = lazy.get()?.isInitialized() != false + + operator fun getValue(thisRef: Any?, property: KProperty<*>): T? = + lazy.get()?.getValue(thisRef, property) + + /** Replace the existing lazy logic with the [newLazy] */ + fun setLazy(newLazy: Lazy<T?>?) { + lazy.set(newLazy) + } + + /** Replace the existing lazy logic with a [Lazy] created from the [newInitializer]. */ + fun setLazy(newInitializer: () -> T?) { + lazy.set(lazy(newInitializer)) + } + + /** Set the lazy logic to null. */ + fun clear() { + lazy.set(null) + } +} + +/** Constructs a [MutableLazy] using the given [initializer] */ +fun <T> mutableLazy(initializer: () -> T?) = MutableLazy(initializer) diff --git a/java/src/com/android/intentresolver/v2/validation/Findings.kt b/java/src/com/android/intentresolver/v2/validation/Findings.kt new file mode 100644 index 00000000..9a3cc9c7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/Findings.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation + +import android.util.Log +import com.android.intentresolver.v2.validation.Importance.CRITICAL +import com.android.intentresolver.v2.validation.Importance.WARNING +import kotlin.reflect.KClass + +sealed interface Finding { + val importance: Importance + val message: String +} + +enum class Importance { + CRITICAL, + WARNING, +} + +val Finding.logcatPriority + get() = + when (importance) { + CRITICAL -> Log.ERROR + else -> Log.WARN + } + +private fun formatMessage(key: String? = null, msg: String) = buildString { + key?.also { append("['$key']: ") } + append(msg) +} + +data class IgnoredValue( + val key: String, + val reason: String, +) : Finding { + override val importance = WARNING + + override val message: String + get() = formatMessage(key, "Ignored. $reason") +} + +data class RequiredValueMissing( + val key: String, + val allowedType: KClass<*>, +) : Finding { + + override val importance = CRITICAL + + override val message: String + get() = + formatMessage( + key, + "expected value of ${allowedType.simpleName}, " + "but no value was present" + ) +} + +data class WrongElementType( + val key: String, + override val importance: Importance, + val container: KClass<*>, + val actualType: KClass<*>, + val expectedType: KClass<*> +) : Finding { + override val message: String + get() = + formatMessage( + key, + "${container.simpleName} expected with elements of " + + "${expectedType.simpleName} " + + "but found ${actualType.simpleName} values instead" + ) +} + +data class ValueIsWrongType( + val key: String, + override val importance: Importance, + val actualType: KClass<*>, + val allowedTypes: List<KClass<*>>, +) : Finding { + + override val message: String + get() = + formatMessage( + key, + "expected value of ${allowedTypes.map(KClass<*>::simpleName)} " + + "but was ${actualType.simpleName}" + ) +} + +data class UncaughtException(val thrown: Throwable, val key: String? = null) : Finding { + override val importance: Importance + get() = CRITICAL + override val message: String + get() = + formatMessage( + key, + "An unhandled exception was caught during validation: " + + thrown.stackTraceToString() + ) +} diff --git a/java/src/com/android/intentresolver/v2/validation/Validation.kt b/java/src/com/android/intentresolver/v2/validation/Validation.kt new file mode 100644 index 00000000..46939602 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/Validation.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation + +import com.android.intentresolver.v2.validation.Importance.CRITICAL +import com.android.intentresolver.v2.validation.Importance.WARNING + +/** + * Provides a mechanism for validating a result from a set of properties. + * + * The results of validation are provided as [findings]. + */ +interface Validation { + val findings: List<Finding> + + /** + * Require a valid property. + * + * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. + * + * @param property the required property + * @return a valid **T** + */ + @Throws(InvalidResultError::class) fun <T> required(property: Validator<T>): T + + /** + * Request an optional value for a property. + * + * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. + * + * @param property the required property + * @return a valid **T** + */ + fun <T> optional(property: Validator<T>): T? + + /** + * Report a property as __ignored__. + * + * The presence of any value will report a warning citing [reason]. + */ + fun <T> ignored(property: Validator<T>, reason: String) +} + +/** Performs validation for a specific key -> value pair. */ +interface Validator<T> { + val key: String + + /** + * Performs validation on a specific value from [source]. + * + * @param source a source for reading the property value. Values are intentionally untyped + * (Any?) to avoid upstream code from making type assertions through type inference. Types are + * asserted later using a [Validator]. + * @param importance the importance of any findings + */ + fun validate(source: (String) -> Any?, importance: Importance): ValidationResult<T> +} + +internal class InvalidResultError internal constructor() : Error() + +/** + * Perform a number of validations on the source, assembling and returning a Result. + * + * When an exception is thrown by [validate], it is caught here. In response, a failed + * [ValidationResult] is returned containing a [CRITICAL] [Finding] for the exception. + * + * @param validate perform validations and return a [ValidationResult] + */ +fun <T> validateFrom(source: (String) -> Any?, validate: Validation.() -> T): ValidationResult<T> { + val validation = ValidationImpl(source) + return runCatching { validate(validation) } + .fold( + onSuccess = { result -> Valid(result, validation.findings) }, + onFailure = { + when (it) { + // A validator has interrupted validation. Return the findings. + is InvalidResultError -> Invalid(validation.findings) + + // Some other exception was thrown from [validate], + else -> Invalid(findings = listOf(UncaughtException(it))) + } + } + ) +} + +private class ValidationImpl(val source: (String) -> Any?) : Validation { + override val findings = mutableListOf<Finding>() + + override fun <T> optional(property: Validator<T>): T? = validate(property, WARNING) + + override fun <T> required(property: Validator<T>): T { + return validate(property, CRITICAL) ?: throw InvalidResultError() + } + + override fun <T> ignored(property: Validator<T>, reason: String) { + val result = property.validate(source, WARNING) + if (result.value != null) { + // Note: Any findings about the value (result.findings) are ignored. + findings += IgnoredValue(property.key, reason) + } + } + + private fun <T> validate(property: Validator<T>, importance: Importance): T? { + return runCatching { property.validate(source, importance) } + .fold( + onSuccess = { result -> + findings += result.findings + result.value + }, + onFailure = { + findings += UncaughtException(it, property.key) + null + } + ) + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt new file mode 100644 index 00000000..092cabe8 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation + +import android.util.Log + +sealed interface ValidationResult<T> { + val value: T? + val findings: List<Finding> + + fun isSuccess() = value != null + + fun getOrThrow(): T = + checkNotNull(value) { "The result was invalid: " + findings.joinToString(separator = "\n") } + + fun <T> reportToLogcat(tag: String) { + findings.forEach { Log.println(it.logcatPriority, tag, it.toString()) } + } +} + +data class Valid<T>(override val value: T?, override val findings: List<Finding> = emptyList()) : + ValidationResult<T> + +data class Invalid<T>(override val findings: List<Finding>) : ValidationResult<T> { + override val value: T? = null +} diff --git a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt new file mode 100644 index 00000000..3cefeb15 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation.types + +import android.content.Intent +import android.net.Uri +import com.android.intentresolver.v2.validation.Importance +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.Validator +import com.android.intentresolver.v2.validation.ValueIsWrongType + +class IntentOrUri(override val key: String) : Validator<Intent> { + + override fun validate( + source: (String) -> Any?, + importance: Importance + ): ValidationResult<Intent> { + + return when (val value = source(key)) { + // An intent, return it. + is Intent -> Valid(value) + + // A Uri was supplied. + // Unfortunately, converting Uri -> Intent requires a toString(). + is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME)) + + // No value present. + null -> createResult(importance, RequiredValueMissing(key, Intent::class)) + + // Some other type. + else -> { + return createResult( + importance, + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(Intent::class, Uri::class) + ) + ) + } + } + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt new file mode 100644 index 00000000..c6c4abba --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation.types + +import com.android.intentresolver.v2.validation.Importance +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.Validator +import com.android.intentresolver.v2.validation.ValueIsWrongType +import com.android.intentresolver.v2.validation.WrongElementType +import kotlin.reflect.KClass +import kotlin.reflect.cast + +class ParceledArray<T : Any>( + override val key: String, + private val elementType: KClass<T>, +) : Validator<List<T>> { + + override fun validate( + source: (String) -> Any?, + importance: Importance + ): ValidationResult<List<T>> { + + return when (val value: Any? = source(key)) { + // No value present. + null -> createResult(importance, RequiredValueMissing(key, elementType)) + + // A parcel does not transfer the element type information for parcelable + // arrays. This leads to a restored type of Array<Parcelable>, which is + // incompatible with Array<T : Parcelable>. + + // To handle this safely, treat as Array<*>, assert contents of the expected + // parcelable type, and return as a list. + + is Array<*> -> { + val invalid = value.filterNotNull().firstOrNull { !elementType.isInstance(it) } + when (invalid) { + // No invalid elements, result is ok. + null -> Valid(value.map { elementType.cast(it) }) + + // At least one incorrect element type found. + else -> + createResult( + importance, + WrongElementType( + key, + importance, + actualType = invalid::class, + container = Array::class, + expectedType = elementType + ) + ) + } + } + + // The value is not an Array at all. + else -> + createResult( + importance, + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(elementType) + ) + ) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt new file mode 100644 index 00000000..3287b84b --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation.types + +import com.android.intentresolver.v2.validation.Importance +import com.android.intentresolver.v2.validation.RequiredValueMissing +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.Validator +import com.android.intentresolver.v2.validation.ValueIsWrongType +import kotlin.reflect.KClass +import kotlin.reflect.cast + +class SimpleValue<T : Any>( + override val key: String, + private val expected: KClass<T>, +) : Validator<T> { + + override fun validate(source: (String) -> Any?, importance: Importance): ValidationResult<T> { + val value: Any? = source(key) + return when { + // The value is present and of the expected type. + expected.isInstance(value) -> return Valid(expected.cast(value)) + + // No value is present. + value == null -> createResult(importance, RequiredValueMissing(key, expected)) + + // The value is some other type. + else -> + createResult( + importance, + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(expected) + ) + ) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt b/java/src/com/android/intentresolver/v2/validation/types/Validators.kt new file mode 100644 index 00000000..4e6e5dff --- /dev/null +++ b/java/src/com/android/intentresolver/v2/validation/types/Validators.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.v2.validation.types + +import com.android.intentresolver.v2.validation.Finding +import com.android.intentresolver.v2.validation.Importance +import com.android.intentresolver.v2.validation.Importance.CRITICAL +import com.android.intentresolver.v2.validation.Importance.WARNING +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.v2.validation.ValidationResult +import com.android.intentresolver.v2.validation.Validator + +inline fun <reified T : Any> value(key: String): Validator<T> { + return SimpleValue(key, T::class) +} + +inline fun <reified T : Any> array(key: String): Validator<List<T>> { + return ParceledArray(key, T::class) +} + +/** + * Convenience function to wrap a finding in an appropriate result type. + * + * An error [finding] is suppressed when [importance] == [WARNING] + */ +internal fun <T> createResult(importance: Importance, finding: Finding): ValidationResult<T> { + return when (importance) { + WARNING -> Valid(null, listOf(finding).filter { it.importance == WARNING }) + CRITICAL -> Invalid(listOf(finding)) + } +} diff --git a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt new file mode 100644 index 00000000..26464ca1 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt @@ -0,0 +1,90 @@ +package com.android.intentresolver.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import androidx.core.view.ScrollingView +import androidx.core.view.marginBottom +import androidx.core.view.marginLeft +import androidx.core.view.marginRight +import androidx.core.view.marginTop +import androidx.core.widget.NestedScrollView + +/** + * A narrowly tailored [NestedScrollView] to be used inside [ResolverDrawerLayout] and help to + * orchestrate content preview scrolling. It expects one [LinearLayout] child with + * [LinearLayout.VERTICAL] orientation. If the child has more than one child, the first its child + * will be made scrollable (it is expected to be a content preview view). + */ +class ChooserNestedScrollView : NestedScrollView { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int + ) : super(context, attrs, defStyleAttr) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val content = + getChildAt(0) as? LinearLayout ?: error("Exactly one child, LinerLayout, is expected") + require(content.orientation == LinearLayout.VERTICAL) { "VERTICAL orientation is expected" } + require(MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { + "Expected to have an exact width" + } + + val lp = content.layoutParams ?: error("LayoutParams is missing") + val contentWidthSpec = + getChildMeasureSpec( + widthMeasureSpec, + paddingLeft + content.marginLeft + content.marginRight + paddingRight, + lp.width + ) + val contentHeightSpec = + getChildMeasureSpec( + heightMeasureSpec, + paddingTop + content.marginTop + content.marginBottom + paddingBottom, + lp.height + ) + content.measure(contentWidthSpec, contentHeightSpec) + + if (content.childCount > 1) { + // We expect that the first child should be scrollable up + val child = content.getChildAt(0) + val height = + MeasureSpec.getSize(heightMeasureSpec) + + child.measuredHeight + + child.marginTop + + child.marginBottom + + content.measure( + contentWidthSpec, + MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)) + ) + } + setMeasuredDimension( + MeasureSpec.getSize(widthMeasureSpec), + minOf( + MeasureSpec.getSize(heightMeasureSpec), + paddingTop + + content.marginTop + + content.measuredHeight + + content.marginBottom + + paddingBottom + ) + ) + } + + override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) { + // let the parent scroll + super.onNestedPreScroll(target, dx, dy, consumed, type) + // scroll ourselves, if recycler has not scrolled + val delta = dy - consumed[1] + if (delta > 0 && target is ScrollingView && !target.canScrollVertically(-1)) { + val preScrollY = scrollY + scrollBy(0, delta) + consumed[1] += scrollY - preScrollY + } + } +} diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index de76a1d2..2c8140d9 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -19,7 +19,6 @@ package com.android.intentresolver.widget; import static android.content.res.Resources.ID_NULL; -import android.annotation.IdRes; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; @@ -45,6 +44,10 @@ import android.view.animation.AnimationUtils; import android.widget.AbsListView; import android.widget.OverScroller; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ScrollingView; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.R; @@ -131,6 +134,9 @@ public class ResolverDrawerLayout extends ViewGroup { private AbsListView mNestedListChild; private RecyclerView mNestedRecyclerChild; + @Nullable + private final ScrollablePreviewFlingLogicDelegate mFlingLogicDelegate; + private final ViewTreeObserver.OnTouchModeChangeListener mTouchModeChangeListener = new ViewTreeObserver.OnTouchModeChangeListener() { @Override @@ -167,6 +173,12 @@ public class ResolverDrawerLayout extends ViewGroup { mIgnoreOffsetTopLimitViewId = a.getResourceId( R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit, ID_NULL); } + mFlingLogicDelegate = + a.getBoolean( + R.styleable.ResolverDrawerLayout_useScrollablePreviewNestedFlingLogic, + false) + ? new ScrollablePreviewFlingLogicDelegate() {} + : null; a.recycle(); mScrollIndicatorDrawable = mContext.getDrawable( @@ -832,6 +844,9 @@ public class ResolverDrawerLayout extends ViewGroup { @Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { + if (mFlingLogicDelegate != null) { + return mFlingLogicDelegate.onNestedPreFling(this, target, velocityX, velocityY); + } if (!getShowAtTop() && velocityY > mMinFlingVelocity && mCollapseOffset != 0) { smoothScrollTo(0, velocityY); return true; @@ -841,9 +856,12 @@ public class ResolverDrawerLayout extends ViewGroup { @Override public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { + if (mFlingLogicDelegate != null) { + return mFlingLogicDelegate.onNestedFling(this, target, velocityX, velocityY, consumed); + } // TODO: find a more suitable way to fix it. // RecyclerView started reporting `consumed` as true whenever a scrolling is enabled, - // previously the value was based whether the fling can be performed in given direction + // previously the value was based on whether the fling can be performed in given direction // i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop method is a // workaround that restores the legacy functionality. boolean shouldConsume = (Math.abs(velocityY) > mMinFlingVelocity) @@ -885,6 +903,13 @@ public class ResolverDrawerLayout extends ViewGroup { && firstChild.getTop() >= recyclerView.getPaddingTop(); } + private static boolean isFlingTargetAtTop(View target) { + if (target instanceof ScrollingView) { + return !target.canScrollVertically(-1); + } + return false; + } + private boolean performAccessibilityActionCommon(int action) { switch (action) { case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: @@ -974,7 +999,7 @@ public class ResolverDrawerLayout extends ViewGroup { } @Override - public void onDrawForeground(Canvas canvas) { + public void onDrawForeground(@NonNull Canvas canvas) { if (mScrollIndicatorDrawable != null) { mScrollIndicatorDrawable.draw(canvas); } @@ -1299,4 +1324,74 @@ public class ResolverDrawerLayout extends ViewGroup { } return mMetricsLogger; } + + /** + * Controlled by + * {@link com.android.intentresolver.Flags#FLAG_SCROLLABLE_PREVIEW} + */ + private interface ScrollablePreviewFlingLogicDelegate { + default boolean onNestedPreFling( + ResolverDrawerLayout drawer, View target, float velocityX, float velocityY) { + boolean shouldScroll = !drawer.getShowAtTop() && velocityY > drawer.mMinFlingVelocity + && drawer.mCollapseOffset != 0; + if (shouldScroll) { + drawer.smoothScrollTo(0, velocityY); + return true; + } + boolean shouldDismiss = (Math.abs(velocityY) > drawer.mMinFlingVelocity) + && velocityY < 0 + && isFlingTargetAtTop(target); + if (shouldDismiss) { + if (drawer.getShowAtTop()) { + drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY); + } else { + if (drawer.isDismissable() + && drawer.mCollapseOffset > drawer.mCollapsibleHeight) { + drawer.smoothScrollTo(drawer.mHeightUsed, velocityY); + drawer.mDismissOnScrollerFinished = true; + } else { + drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY); + } + } + return true; + } + return false; + } + + default boolean onNestedFling( + ResolverDrawerLayout drawer, + View target, + float velocityX, + float velocityY, + boolean consumed) { + // TODO: find a more suitable way to fix it. + // RecyclerView started reporting `consumed` as true whenever a scrolling is enabled, + // previously the value was based on whether the fling can be performed in given + // direction i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop + // method is a workaround that restores the legacy functionality. + boolean shouldConsume = (Math.abs(velocityY) > drawer.mMinFlingVelocity) && !consumed; + if (shouldConsume) { + if (drawer.getShowAtTop()) { + if (drawer.isDismissable() && velocityY > 0) { + drawer.abortAnimation(); + drawer.dismiss(); + } else { + drawer.smoothScrollTo( + velocityY < 0 ? drawer.mCollapsibleHeight : 0, velocityY); + } + } else { + if (drawer.isDismissable() + && velocityY < 0 + && drawer.mCollapseOffset > drawer.mCollapsibleHeight) { + drawer.smoothScrollTo(drawer.mHeightUsed, velocityY); + drawer.mDismissOnScrollerFinished = true; + } else { + drawer.smoothScrollTo( + velocityY > 0 ? 0 : drawer.mCollapsibleHeight, velocityY); + } + } + } + return shouldConsume; + } + } } diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 3bbafc40..7fe16091 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -26,11 +26,16 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.Animation.AnimationListener +import android.view.animation.DecelerateInterpolator import android.widget.ImageView import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.intentresolver.R @@ -45,6 +50,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine private const val TRANSITION_NAME = "screenshot_preview_image" private const val PLURALS_COUNT = "count" @@ -65,7 +71,6 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { defStyleAttr: Int ) : super(context, attrs, defStyleAttr) { layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - adapter = Adapter(context) context .obtainStyledAttributes(attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0) @@ -98,11 +103,14 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { ) .toInt() } - addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing)) + super.addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing)) maxWidthHint = a.getDimensionPixelSize(R.styleable.ScrollableImagePreviewView_maxWidthHint, -1) } + val itemAnimator = ItemAnimator() + super.setItemAnimator(itemAnimator) + super.setAdapter(Adapter(context, itemAnimator.getAddDuration())) } private var batchLoader: BatchPreviewLoader? = null @@ -167,6 +175,14 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { return null } + override fun setAdapter(adapter: RecyclerView.Adapter<*>?) { + error("This method is not supported") + } + + override fun setItemAnimator(animator: RecyclerView.ItemAnimator?) { + error("This method is not supported") + } + fun setImageLoader(imageLoader: CachingImageLoader) { previewAdapter.imageLoader = imageLoader } @@ -269,7 +285,10 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { File } - private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() { + private class Adapter( + private val context: Context, + private val fadeInDurationMs: Long, + ) : RecyclerView.Adapter<ViewHolder>() { private val previews = ArrayList<Preview>() private val imagePreviewDescription = context.resources.getString(R.string.image_preview_a11y_description) @@ -311,15 +330,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { if (newPreviews.isEmpty()) return val insertPos = previews.size val hadOtherItem = hasOtherItem - val wasEmpty = previews.isEmpty() + val oldItemCount = getItemCount() previews.addAll(newPreviews) if (firstImagePos < 0) { val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image } if (pos >= 0) firstImagePos = insertPos + pos } - if (wasEmpty) { - // we don't want any item animation in that case - notifyDataSetChanged() + if (insertPos == 0) { + if (oldItemCount > 0) { + notifyItemRangeRemoved(0, oldItemCount) + } + notifyItemRangeInserted(insertPos, getItemCount()) } else { notifyItemRangeInserted(insertPos, newPreviews.size) when { @@ -366,6 +387,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { vh.bind( previews[position], imageLoader ?: error("ImageLoader is missing"), + fadeInDurationMs, isSharedTransitionElement = position == firstImagePos, previewReadyCallback = if ( @@ -416,10 +438,13 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { fun bind( preview: Preview, imageLoader: CachingImageLoader, + fadeInDurationMs: Long, isSharedTransitionElement: Boolean, previewReadyCallback: ((String) -> Unit)? ) { image.setImageDrawable(null) + image.alpha = 1f + image.clearAnimation() (image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params -> params.dimensionRatio = preview.aspectRatioString } @@ -453,11 +478,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } resetScope().launch { loadImage(preview, imageLoader) - if (preview.type == PreviewType.Image) { - previewReadyCallback?.let { callback -> - image.waitForPreDraw() - callback(TRANSITION_NAME) - } + if (preview.type == PreviewType.Image && previewReadyCallback != null) { + image.waitForPreDraw() + previewReadyCallback(TRANSITION_NAME) + } else if (image.isAttachedToWindow()) { + fadeInPreview(fadeInDurationMs) } } } @@ -473,6 +498,30 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { image.setImageBitmap(bitmap) } + private suspend fun fadeInPreview(durationMs: Long) = + suspendCancellableCoroutine { continuation -> + val animation = + AlphaAnimation(0f, 1f).apply { + duration = durationMs + interpolator = DecelerateInterpolator() + setAnimationListener( + object : AnimationListener { + override fun onAnimationStart(animation: Animation?) = Unit + override fun onAnimationRepeat(animation: Animation?) = Unit + + override fun onAnimationEnd(animation: Animation?) { + continuation.resumeWith(Result.success(Unit)) + } + } + ) + } + image.startAnimation(animation) + continuation.invokeOnCancellation { + image.clearAnimation() + image.alpha = 1f + } + } + private fun resetScope(): CoroutineScope = CoroutineScope(Dispatchers.Main.immediate).also { scope?.cancel() @@ -521,6 +570,70 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } + /** + * ItemAnimator to handle a special case of addng first image items into the view. The view is + * used with wrap_content width spec thus after adding the first views it, generally, changes + * its size and position breaking the animation. This class handles that by preserving loading + * idicator position in this special case. + */ + private inner class ItemAnimator() : DefaultItemAnimator() { + private var animatedVH: ViewHolder? = null + private var originalTranslation = 0f + + override fun recordPreLayoutInformation( + state: State, + viewHolder: RecyclerView.ViewHolder, + changeFlags: Int, + payloads: MutableList<Any> + ): ItemHolderInfo { + return super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads).let { + holderInfo -> + if (viewHolder is LoadingItemViewHolder && getChildCount() == 1) { + LoadingItemHolderInfo(holderInfo, parentLeft = left) + } else { + holderInfo + } + } + } + + override fun animateDisappearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo, + postLayoutInfo: ItemHolderInfo? + ): Boolean { + if (viewHolder is LoadingItemViewHolder && preLayoutInfo is LoadingItemHolderInfo) { + val view = viewHolder.itemView + animatedVH = viewHolder + originalTranslation = view.getTranslationX() + view.setTranslationX( + (preLayoutInfo.parentLeft - left + preLayoutInfo.left).toFloat() - view.left + ) + } + return super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) + } + + override fun onRemoveFinished(viewHolder: RecyclerView.ViewHolder) { + if (animatedVH === viewHolder) { + viewHolder.itemView.setTranslationX(originalTranslation) + animatedVH = null + } + super.onRemoveFinished(viewHolder) + } + + private inner class LoadingItemHolderInfo( + holderInfo: ItemHolderInfo, + val parentLeft: Int, + ) : ItemHolderInfo() { + init { + left = holderInfo.left + top = holderInfo.top + right = holderInfo.right + bottom = holderInfo.bottom + changeFlags = holderInfo.changeFlags + } + } + } + @VisibleForTesting class BatchPreviewLoader( private val imageLoader: CachingImageLoader, |