summaryrefslogtreecommitdiff
path: root/java/src
diff options
context:
space:
mode:
Diffstat (limited to 'java/src')
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java38
-rw-r--r--java/src/com/android/intentresolver/ChooserListAdapter.java76
-rw-r--r--java/src/com/android/intentresolver/ChooserRequestParameters.java28
-rw-r--r--java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java2
-rw-r--r--java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java2
-rw-r--r--java/src/com/android/intentresolver/ContentTypeHint.kt25
-rw-r--r--java/src/com/android/intentresolver/PackagesChangedListener.kt22
-rw-r--r--java/src/com/android/intentresolver/ResolverListAdapter.java48
-rw-r--r--java/src/com/android/intentresolver/StartsSelectedItem.kt21
-rw-r--r--java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java15
-rw-r--r--java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt18
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java56
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java4
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java17
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt147
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java8
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java7
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt6
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt4
-rw-r--r--java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt29
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt382
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt82
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt127
-rw-r--r--java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt97
-rw-r--r--java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt175
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt131
-rw-r--r--java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt75
-rw-r--r--java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java16
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java7
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt116
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt66
-rw-r--r--java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt48
-rw-r--r--java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt98
-rw-r--r--java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt145
-rw-r--r--java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt90
-rw-r--r--java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java2
-rw-r--r--java/src/com/android/intentresolver/grid/ChooserGridAdapter.java44
-rw-r--r--java/src/com/android/intentresolver/icon/ComposeIcon.kt88
-rw-r--r--java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt16
-rw-r--r--java/src/com/android/intentresolver/inject/FrameworkModule.kt76
-rw-r--r--java/src/com/android/intentresolver/inject/SystemServices.kt102
-rw-r--r--java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java32
-rw-r--r--java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt8
-rw-r--r--java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt5
-rw-r--r--java/src/com/android/intentresolver/v2/ActivityLogic.kt123
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserActionFactory.java80
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserActivity.java1428
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt72
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserHelper.kt81
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserListController.java66
-rw-r--r--java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt54
-rw-r--r--java/src/com/android/intentresolver/v2/IntentForwarding.kt111
-rw-r--r--java/src/com/android/intentresolver/v2/JavaFlowHelper.kt28
-rw-r--r--java/src/com/android/intentresolver/v2/ProfileAvailability.kt79
-rw-r--r--java/src/com/android/intentresolver/v2/ProfileHelper.kt74
-rw-r--r--java/src/com/android/intentresolver/v2/ResolverActivity.java939
-rw-r--r--java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt69
-rw-r--r--java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt26
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt70
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt4
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt85
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt7
-rw-r--r--java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt2
-rw-r--r--java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt94
-rw-r--r--java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java2
-rw-r--r--java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java116
-rw-r--r--java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt34
-rw-r--r--java/src/com/android/intentresolver/v2/ext/IntentExt.kt45
-rw-r--r--java/src/com/android/intentresolver/v2/ext/ParcelExt.kt27
-rw-r--r--java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt45
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java31
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java (renamed from java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java)45
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java (renamed from java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java)492
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java46
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java27
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java82
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java (renamed from java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java)49
-rw-r--r--java/src/com/android/intentresolver/v2/profiles/TabConfig.java38
-rw-r--r--java/src/com/android/intentresolver/v2/shared/model/Profile.kt52
-rw-r--r--java/src/com/android/intentresolver/v2/shared/model/User.kt (renamed from java/src/com/android/intentresolver/v2/data/model/User.kt)23
-rw-r--r--java/src/com/android/intentresolver/v2/ui/ActionTitle.java1
-rw-r--r--java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt53
-rw-r--r--java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt163
-rw-r--r--java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt80
-rw-r--r--java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt209
-rw-r--r--java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt68
-rw-r--r--java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt23
-rw-r--r--java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt198
-rw-r--r--java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt67
-rw-r--r--java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt59
-rw-r--r--java/src/com/android/intentresolver/v2/validation/Findings.kt17
-rw-r--r--java/src/com/android/intentresolver/v2/validation/Validation.kt18
-rw-r--r--java/src/com/android/intentresolver/v2/validation/ValidationResult.kt23
-rw-r--r--java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt11
-rw-r--r--java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt16
-rw-r--r--java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt13
-rw-r--r--java/src/com/android/intentresolver/v2/validation/types/Validators.kt19
-rw-r--r--java/src/com/android/intentresolver/widget/BadgeTextView.kt88
98 files changed, 6462 insertions, 1911 deletions
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index 9000ab3a..039fad56 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -125,7 +125,7 @@ import javax.inject.Inject;
*/
@AndroidEntryPoint(ResolverActivity.class)
public class ChooserActivity extends Hilt_ChooserActivity implements
- ResolverListAdapter.ResolverListCommunicator {
+ ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem {
private static final String TAG = "ChooserActivity";
/**
@@ -259,7 +259,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
new AppPredictorFactory(
this,
mChooserRequest.getSharedText(),
- mChooserRequest.getTargetIntentFilter()),
+ mChooserRequest.getTargetIntentFilter(),
+ getPackageManager().getAppPredictionServicePackageName() != null),
mChooserRequest.getTargetIntentFilter());
@@ -302,14 +303,24 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
BasePreviewViewModel previewViewModel =
new ViewModelProvider(this, createPreviewViewModelFactory())
.get(BasePreviewViewModel.class);
+ previewViewModel.init(
+ mChooserRequest.getTargetIntent(),
+ getIntent(),
+ /*additionalContentUri = */ null,
+ /*focusedItemIdx = */ 0,
+ /*isPayloadTogglingEnabled = */ false);
mChooserContentPreviewUi = new ChooserContentPreviewUi(
getCoroutineScope(getLifecycle()),
- previewViewModel.createOrReuseProvider(mChooserRequest.getTargetIntent()),
+ previewViewModel.getPreviewDataProvider(),
mChooserRequest.getTargetIntent(),
- previewViewModel.createOrReuseImageLoader(),
+ previewViewModel.getImageLoader(),
createChooserActionFactory(),
mEnterTransitionAnimationDelegate,
- new HeadlineGeneratorImpl(this));
+ new HeadlineGeneratorImpl(this),
+ ContentTypeHint.NONE,
+ mChooserRequest.getMetadataText(),
+ /*isPayloadTogglingEnabled =*/ false
+ );
updateStickyContentPreview();
if (shouldShowStickyContentPreview()
@@ -564,6 +575,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
/**
* Update UI to reflect changes in data.
*/
+ @Override
public void handlePackagesChanged() {
handlePackagesChanged(/* listAdapter */ null);
}
@@ -1206,13 +1218,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
showTargetDetails(longPressedTargetInfo);
}
}
-
- @Override
- public void updateProfileViewButton(View newButtonFromProfileRow) {
- mProfileView = newButtonFromProfileRow;
- mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick);
- ChooserActivity.this.updateProfileViewButton();
- }
},
chooserListAdapter,
shouldShowContentPreview(),
@@ -1252,7 +1257,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
maxTargetsPerRow,
initialIntentsUserSpace,
targetDataLoader,
- null);
+ null,
+ mFeatureFlags);
}
@Override
@@ -1409,7 +1415,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0;
int rowsToShow = gridAdapter.getSystemRowCount()
- + gridAdapter.getProfileRowCount()
+ gridAdapter.getServiceTargetRowCount()
+ gridAdapter.getCallerAndRankedTargetRowCount();
@@ -1657,8 +1662,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (!shouldShowContentPreview()) {
return false;
}
- boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle(
- UserHandle.of(UserHandle.myUserId())).getCount() == 0;
+ ResolverListAdapter adapter = mMultiProfilePagerAdapter.getListAdapterForUserHandle(
+ UserHandle.of(UserHandle.myUserId()));
+ boolean isEmpty = adapter == null || adapter.getCount() == 0;
return (mFeatureFlags.scrollablePreview() || shouldShowTabs())
&& (!isEmpty || shouldShowContentPreviewWhenEmpty());
}
diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java
index 876ad5c3..5060f4f1 100644
--- a/java/src/com/android/intentresolver/ChooserListAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserListAdapter.java
@@ -54,6 +54,7 @@ import com.android.intentresolver.chooser.SelectableTargetInfo;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.intentresolver.icons.TargetDataLoader;
import com.android.intentresolver.logging.EventLog;
+import com.android.intentresolver.widget.BadgeTextView;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
@@ -109,6 +110,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
// Reserve spots for incoming direct share targets by adding placeholders
private final TargetInfo mPlaceHolderTargetInfo;
private final TargetDataLoader mTargetDataLoader;
+ private final boolean mUseBadgeTextViewForLabels;
private final List<TargetInfo> mServiceTargets = new ArrayList<>();
private final List<DisplayResolveInfo> mCallerTargets = new ArrayList<>();
@@ -166,7 +168,8 @@ public class ChooserListAdapter extends ResolverListAdapter {
int maxRankedTargets,
UserHandle initialIntentsUserSpace,
TargetDataLoader targetDataLoader,
- @Nullable PackageChangeCallback packageChangeCallback) {
+ @Nullable PackageChangeCallback packageChangeCallback,
+ FeatureFlags featureFlags) {
this(
context,
payloadIntents,
@@ -185,7 +188,8 @@ public class ChooserListAdapter extends ResolverListAdapter {
targetDataLoader,
packageChangeCallback,
AsyncTask.SERIAL_EXECUTOR,
- context.getMainExecutor());
+ context.getMainExecutor(),
+ featureFlags);
}
@VisibleForTesting
@@ -207,7 +211,8 @@ public class ChooserListAdapter extends ResolverListAdapter {
TargetDataLoader targetDataLoader,
@Nullable PackageChangeCallback packageChangeCallback,
Executor bgExecutor,
- Executor mainExecutor) {
+ Executor mainExecutor,
+ FeatureFlags featureFlags) {
// Don't send the initial intents through the shared ResolverActivity path,
// we want to separate them into a different section.
super(
@@ -231,6 +236,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context);
mTargetDataLoader = targetDataLoader;
mPackageChangeCallback = packageChangeCallback;
+ mUseBadgeTextViewForLabels = featureFlags.bespokeLabelView();
createPlaceHolders();
mEventLog = eventLog;
mShortcutSelectionLogic = new ShortcutSelectionLogic(
@@ -332,7 +338,12 @@ public class ChooserListAdapter extends ResolverListAdapter {
@Override
View onCreateView(ViewGroup parent) {
- return mInflater.inflate(R.layout.resolve_grid_item, parent, false);
+ return mInflater.inflate(
+ mUseBadgeTextViewForLabels
+ ? R.layout.chooser_grid_item
+ : R.layout.resolve_grid_item,
+ parent,
+ false);
}
@VisibleForTesting
@@ -340,7 +351,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
public void onBindView(View view, TargetInfo info, int position) {
final ViewHolder holder = (ViewHolder) view.getTag();
- holder.reset();
+ resetViewHolder(holder);
// Always remove the spacing listener, attach as needed to direct share targets below.
holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener);
@@ -377,16 +388,18 @@ public class ChooserListAdapter extends ResolverListAdapter {
contentDescription,
mContext.getResources().getString(R.string.pinned));
}
- holder.updateContentDescription(contentDescription);
+ updateContentDescription(holder, 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)));
+ updateContentDescription(
+ holder,
+ String.join(
+ ". ",
+ info.getDisplayLabel(),
+ mContext.getResources().getString(R.string.pinned)));
}
DisplayResolveInfo dri = (DisplayResolveInfo) info;
if (!dri.hasDisplayIcon()) {
@@ -398,22 +411,56 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
if (info.isPlaceHolderTargetInfo()) {
- holder.bindPlaceholder();
+ bindPlaceholder(holder);
}
if (info.isMultiDisplayResolveInfo()) {
// If the target is grouped show an indicator
- holder.bindGroupIndicator(
+ bindGroupIndicator(
+ holder,
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
- holder.bindPinnedIndicator(mContext.getDrawable(R.drawable.chooser_pinned_background));
+ bindPinnedIndicator(holder, mContext.getDrawable(R.drawable.chooser_pinned_background));
holder.text.addOnLayoutChangeListener(mPinTextSpacingListener);
}
}
+ private void resetViewHolder(ViewHolder holder) {
+ holder.reset();
+ holder.itemView.setBackground(holder.defaultItemViewBackground);
+
+ if (mUseBadgeTextViewForLabels) {
+ ((BadgeTextView) holder.text).setBadgeDrawable(null);
+ }
+ holder.text.setBackground(null);
+ holder.text.setPaddingRelative(0, 0, 0, 0);
+ }
+
+ private void updateContentDescription(ViewHolder holder, String description) {
+ holder.itemView.setContentDescription(description);
+ }
+
+ private void bindPlaceholder(ViewHolder holder) {
+ holder.itemView.setBackground(null);
+ }
+
+ private void bindGroupIndicator(ViewHolder holder, Drawable indicator) {
+ if (mUseBadgeTextViewForLabels) {
+ ((BadgeTextView) holder.text).setBadgeDrawable(indicator);
+ } else {
+ holder.text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0);
+ holder.text.setBackground(indicator);
+ }
+ }
+
+ private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) {
+ holder.text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0);
+ holder.text.setBackground(indicator);
+ }
+
private void loadDirectShareIcon(SelectableTargetInfo info) {
if (mRequestedIcons.add(info)) {
mTargetDataLoader.loadDirectShareIcon(
@@ -744,9 +791,6 @@ public class ChooserListAdapter extends ResolverListAdapter {
@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/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java
index 7ad809e9..6c7f8264 100644
--- a/java/src/com/android/intentresolver/ChooserRequestParameters.java
+++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java
@@ -16,6 +16,8 @@
package com.android.intentresolver;
+import static java.util.Objects.requireNonNullElse;
+
import android.content.ComponentName;
import android.content.Intent;
import android.content.IntentFilter;
@@ -41,6 +43,8 @@ import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -101,6 +105,9 @@ public class ChooserRequestParameters {
@Nullable
private final IntentFilter mTargetIntentFilter;
+ @Nullable
+ private final CharSequence mMetadataText;
+
public ChooserRequestParameters(
final Intent clientIntent,
String referrerPackageName,
@@ -125,8 +132,14 @@ public class ChooserRequestParameters {
mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer);
- mChosenComponentSender = clientIntent.getParcelableExtra(
- Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER);
+ mChosenComponentSender =
+ Optional.ofNullable(
+ clientIntent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER,
+ IntentSender.class))
+ .orElse(clientIntent.getParcelableExtra(
+ Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER,
+ IntentSender.class));
+
mRefinementIntentSender = clientIntent.getParcelableExtra(
Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER);
@@ -147,6 +160,12 @@ public class ChooserRequestParameters {
mChooserActions = getChooserActions(clientIntent);
mModifyShareAction = getModifyShareAction(clientIntent);
+
+ if (android.service.chooser.Flags.enableSharesheetMetadataExtra()) {
+ mMetadataText = clientIntent.getCharSequenceExtra(Intent.EXTRA_METADATA_TEXT);
+ } else {
+ mMetadataText = null;
+ }
}
public Intent getTargetIntent() {
@@ -252,6 +271,11 @@ public class ChooserRequestParameters {
return mTargetIntentFilter;
}
+ @Nullable
+ public CharSequence getMetadataText() {
+ return mMetadataText;
+ }
+
private static boolean isSendAction(@Nullable String action) {
return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action));
}
diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java
index f0fcd149..30e69c18 100644
--- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java
+++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java
@@ -63,7 +63,7 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF
@Override
public void onClick(DialogInterface dialog, int which) {
mMultiDisplayResolveInfo.setSelected(which);
- ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true);
+ ((StartsSelectedItem) getActivity()).startSelected(mParentWhich, false, true);
dismiss();
}
diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java
index b6b7de96..ae80fad4 100644
--- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java
+++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java
@@ -205,7 +205,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment
} else {
pinComponent(mTargetInfos.get(which).getResolvedComponentName());
}
- ((ChooserActivity) getActivity()).handlePackagesChanged();
+ ((PackagesChangedListener) getActivity()).handlePackagesChanged();
dismiss();
}
diff --git a/java/src/com/android/intentresolver/ContentTypeHint.kt b/java/src/com/android/intentresolver/ContentTypeHint.kt
new file mode 100644
index 00000000..f607e4ae
--- /dev/null
+++ b/java/src/com/android/intentresolver/ContentTypeHint.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver
+
+import android.content.Intent
+
+/** Enum reflecting the value of [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT]. */
+enum class ContentTypeHint {
+ NONE,
+ ALBUM,
+}
diff --git a/java/src/com/android/intentresolver/PackagesChangedListener.kt b/java/src/com/android/intentresolver/PackagesChangedListener.kt
new file mode 100644
index 00000000..10f0bf51
--- /dev/null
+++ b/java/src/com/android/intentresolver/PackagesChangedListener.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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
+
+/** A component which can be notified when packages have changed. */
+interface PackagesChangedListener {
+ /** Report that packages have changed. */
+ fun handlePackagesChanged()
+}
diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java
index 564d8d19..80d07d2c 100644
--- a/java/src/com/android/intentresolver/ResolverListAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverListAdapter.java
@@ -25,7 +25,6 @@ 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;
@@ -477,9 +476,6 @@ public class ResolverListAdapter extends BaseAdapter {
@Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) {
processSortedList(sortedComponents, doPostProcessing);
notifyDataSetChanged();
- if (doPostProcessing) {
- mResolverListCommunicator.updateProfileViewButton();
- }
}
protected void processSortedList(
@@ -651,6 +647,7 @@ public class ResolverListAdapter extends BaseAdapter {
return null;
}
+ @Override
public int getCount() {
int totalSize = mDisplayList == null || mDisplayList.isEmpty() ? mPlaceholderCount :
mDisplayList.size();
@@ -664,6 +661,7 @@ public class ResolverListAdapter extends BaseAdapter {
return mDisplayList.size();
}
+ @Override
@Nullable
public TargetInfo getItem(int position) {
if (mFilterLastUsed && mLastChosenPosition >= 0 && position >= mLastChosenPosition) {
@@ -676,6 +674,7 @@ public class ResolverListAdapter extends BaseAdapter {
}
}
+ @Override
public long getItemId(int position) {
return position;
}
@@ -693,6 +692,7 @@ public class ResolverListAdapter extends BaseAdapter {
return mDisplayList.get(index);
}
+ @Override
public final View getView(int position, View convertView, ViewGroup parent) {
View view = convertView;
if (view == null) {
@@ -753,9 +753,7 @@ public class ResolverListAdapter extends BaseAdapter {
}
private void onIconLoaded(DisplayResolveInfo displayResolveInfo, Drawable drawable) {
- if (getOtherProfile() == displayResolveInfo) {
- mResolverListCommunicator.updateProfileViewButton();
- } else if (!displayResolveInfo.hasDisplayIcon()) {
+ if (!displayResolveInfo.hasDisplayIcon()) {
displayResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable);
notifyDataSetChanged();
}
@@ -903,14 +901,18 @@ public class ResolverListAdapter extends BaseAdapter {
Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent);
+ // ResolverListCommunicator
+ default void updateProfileViewButton() {
+ }
+
void onPostListReady(ResolverListAdapter listAdapter, boolean updateUi,
boolean rebuildCompleted);
void sendVoiceChoicesIfNeeded();
- void updateProfileViewButton();
-
- boolean useLayoutWithDefault();
+ default boolean useLayoutWithDefault() {
+ return false;
+ }
boolean shouldGetActivityMetadata();
@@ -918,7 +920,9 @@ public class ResolverListAdapter extends BaseAdapter {
* @return true to filter only apps that can handle
* {@link android.content.Intent#CATEGORY_DEFAULT} intents
*/
- default boolean shouldGetOnlyDefaultActivities() { return true; };
+ default boolean shouldGetOnlyDefaultActivities() {
+ return true;
+ }
void onHandlePackagesChanged(ResolverListAdapter listAdapter);
}
@@ -930,7 +934,7 @@ public class ResolverListAdapter extends BaseAdapter {
@VisibleForTesting
public static class ViewHolder {
public View itemView;
- public Drawable defaultItemViewBackground;
+ public final Drawable defaultItemViewBackground;
public TextView text;
public TextView text2;
@@ -940,8 +944,6 @@ public class ResolverListAdapter extends BaseAdapter {
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("");
@@ -982,10 +984,6 @@ public class ResolverListAdapter extends BaseAdapter {
itemView.setContentDescription(null);
}
- public void updateContentDescription(String description) {
- itemView.setContentDescription(description);
- }
-
/**
* Bind view holder to a TargetInfo.
*/
@@ -998,19 +996,5 @@ 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/StartsSelectedItem.kt b/java/src/com/android/intentresolver/StartsSelectedItem.kt
new file mode 100644
index 00000000..01cdf124
--- /dev/null
+++ b/java/src/com/android/intentresolver/StartsSelectedItem.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 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
+
+interface StartsSelectedItem {
+ /** Start the selected item. */
+ fun startSelected(which: Int, always: Boolean, filtered: Boolean)
+}
diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
index b97e6b45..4fe28384 100644
--- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
+++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
@@ -17,9 +17,11 @@
package com.android.intentresolver.chooser;
import android.app.Activity;
+import android.content.ComponentName;
import android.content.Intent;
import android.os.Bundle;
import android.os.UserHandle;
+import android.util.Log;
import androidx.annotation.Nullable;
@@ -121,6 +123,19 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo {
}
@Override
+ public ComponentName getResolvedComponentName() {
+ if (hasSelected()) {
+ return mTargetInfos.get(mSelected).getResolvedComponentName();
+ }
+ // It is not expected to have this method be called on an unselected multi-display item.
+ // Call super to preserve the legacy (most likely erroneous) behavior.
+ Log.wtf(
+ "ChooserActivity",
+ "retrieving ResolvedComponentName from an unselected MultiDisplayResolveInfo");
+ return super.getResolvedComponentName();
+ }
+
+ @Override
public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
return mTargetInfos.get(mSelected).startAsUser(activity, options, user);
}
diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
index 10ee5af1..21c909ea 100644
--- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
@@ -17,16 +17,22 @@
package com.android.intentresolver.contentpreview
import android.content.Intent
+import android.net.Uri
import androidx.annotation.MainThread
import androidx.lifecycle.ViewModel
-import com.android.intentresolver.ChooserRequestParameters
/** A contract for the preview view model. Added for testing. */
abstract class BasePreviewViewModel : ViewModel() {
- @MainThread
- abstract fun createOrReuseProvider(
- targetIntent: Intent
- ): PreviewDataProvider
+ @get:MainThread abstract val previewDataProvider: PreviewDataProvider
+ @get:MainThread abstract val imageLoader: ImageLoader
+ abstract val payloadToggleInteractor: PayloadToggleInteractor?
- @MainThread abstract fun createOrReuseImageLoader(): ImageLoader
+ @MainThread
+ abstract fun init(
+ targetIntent: Intent,
+ chooserIntent: Intent,
+ additionalContentUri: Uri?,
+ focusedItemIdx: Int,
+ isPayloadTogglingEnabled: Boolean,
+ )
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
index a015147d..6f201ad5 100644
--- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
@@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview;
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE;
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE;
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION;
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT;
import android.content.ClipData;
@@ -32,6 +33,7 @@ import android.view.ViewGroup;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
+import com.android.intentresolver.ContentTypeHint;
import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback;
@@ -48,6 +50,7 @@ import kotlinx.coroutines.CoroutineScope;
public final class ChooserContentPreviewUi {
private final CoroutineScope mScope;
+ private final boolean mIsPayloadTogglingEnabled;
/**
* Delegate to build the default system action buttons to display in the preview layout, if/when
@@ -98,8 +101,13 @@ public final class ChooserContentPreviewUi {
ImageLoader imageLoader,
ActionFactory actionFactory,
TransitionElementStatusCallback transitionElementStatusCallback,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint,
+ @Nullable CharSequence metadata,
+ // TODO: replace with the FeatureFlag ref when v1 is gone
+ boolean isPayloadTogglingEnabled) {
mScope = scope;
+ mIsPayloadTogglingEnabled = isPayloadTogglingEnabled;
mContentPreviewUi = createContentPreview(
previewData,
targetIntent,
@@ -107,7 +115,10 @@ public final class ChooserContentPreviewUi {
imageLoader,
actionFactory,
transitionElementStatusCallback,
- headlineGenerator);
+ headlineGenerator,
+ contentTypeHint,
+ metadata
+ );
if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) {
transitionElementStatusCallback.onAllTransitionElementsReady();
}
@@ -120,8 +131,10 @@ public final class ChooserContentPreviewUi {
ImageLoader imageLoader,
ActionFactory actionFactory,
TransitionElementStatusCallback transitionElementStatusCallback,
- HeadlineGenerator headlineGenerator) {
-
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint,
+ @Nullable CharSequence metadata
+ ) {
int previewType = previewData.getPreviewType();
if (previewType == CONTENT_PREVIEW_TEXT) {
return createTextPreview(
@@ -129,20 +142,31 @@ public final class ChooserContentPreviewUi {
targetIntent,
actionFactory,
imageLoader,
- headlineGenerator);
+ headlineGenerator,
+ contentTypeHint,
+ metadata
+ );
}
if (previewType == CONTENT_PREVIEW_FILE) {
FileContentPreviewUi fileContentPreviewUi = new FileContentPreviewUi(
previewData.getUriCount(),
actionFactory,
- headlineGenerator);
+ headlineGenerator,
+ metadata
+ );
if (previewData.getUriCount() > 0) {
previewData.getFirstFileName(mScope, fileContentPreviewUi::setFirstFileName);
}
return fileContentPreviewUi;
}
+
+ if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && mIsPayloadTogglingEnabled) {
+ transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO
+ return new ShareouselContentPreviewUi(actionFactory);
+ }
+
boolean isSingleImageShare = previewData.getUriCount() == 1
- && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType());
+ && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType());
CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
if (!TextUtils.isEmpty(text)) {
FilesPlusTextContentPreviewUi previewUi =
@@ -155,7 +179,9 @@ public final class ChooserContentPreviewUi {
actionFactory,
imageLoader,
typeClassifier,
- headlineGenerator);
+ headlineGenerator,
+ metadata
+ );
if (previewData.getUriCount() > 0) {
JavaFlowHelper.collectToList(
mScope,
@@ -175,7 +201,9 @@ public final class ChooserContentPreviewUi {
transitionElementStatusCallback,
previewData.getImagePreviewFileInfoFlow(),
previewData.getUriCount(),
- headlineGenerator);
+ headlineGenerator,
+ metadata
+ );
}
public int getPreferredContentPreview() {
@@ -200,7 +228,10 @@ public final class ChooserContentPreviewUi {
Intent targetIntent,
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint,
+ @Nullable CharSequence metadata
+ ) {
CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
CharSequence previewTitle = targetIntent.getCharSequenceExtra(Intent.EXTRA_TITLE);
ClipData previewData = targetIntent.getClipData();
@@ -211,13 +242,16 @@ public final class ChooserContentPreviewUi {
previewThumbnail = previewDataItem.getUri();
}
}
+
return new TextContentPreviewUi(
scope,
sharingText,
previewTitle,
+ metadata,
previewThumbnail,
actionFactory,
imageLoader,
- headlineGenerator);
+ headlineGenerator,
+ contentTypeHint);
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
index ad1c6c01..79bb9d3c 100644
--- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java
@@ -25,11 +25,13 @@ import java.lang.annotation.Retention;
@Retention(SOURCE)
@IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE,
ContentPreviewType.CONTENT_PREVIEW_IMAGE,
- ContentPreviewType.CONTENT_PREVIEW_TEXT})
+ ContentPreviewType.CONTENT_PREVIEW_TEXT,
+ ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION})
public @interface ContentPreviewType {
// Starting at 1 since 0 is considered "undefined" for some of the database transformations
// of tron logs.
int CONTENT_PREVIEW_IMAGE = 1;
int CONTENT_PREVIEW_FILE = 2;
int CONTENT_PREVIEW_TEXT = 3;
+ int CONTENT_PREVIEW_PAYLOAD_SELECTION = 4;
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
index dce146b0..b0fb278e 100644
--- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
@@ -30,12 +30,14 @@ import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
import com.android.intentresolver.R;
import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ScrollableImagePreviewView;
-abstract class ContentPreviewUi {
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+public abstract class ContentPreviewUi {
private static final int IMAGE_FADE_IN_MILLIS = 150;
static final String TAG = "ChooserPreview";
@@ -83,6 +85,19 @@ abstract class ContentPreviewUi {
}
}
+ protected static void displayMetadata(View layout, @Nullable CharSequence metadata) {
+ TextView metadataView = layout == null ? null : layout.findViewById(R.id.metadata);
+ if (metadataView == null) {
+ return;
+ }
+ if (!TextUtils.isEmpty(metadata)) {
+ metadataView.setText(metadata);
+ metadataView.setVisibility(View.VISIBLE);
+ } else {
+ metadataView.setVisibility(View.GONE);
+ }
+ }
+
protected static void displayModifyShareAction(
View layout, ChooserContentPreviewUi.ActionFactory actionFactory) {
ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction();
diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt
new file mode 100644
index 00000000..6a12f56c
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.ContentInterface
+import android.content.Intent
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.Bundle
+import android.os.CancellationSignal
+import android.service.chooser.AdditionalContentContract.Columns
+import android.service.chooser.AdditionalContentContract.CursorExtraKeys
+import android.util.Log
+import android.util.SparseArray
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.coroutineScope
+
+private const val TAG = ContentPreviewUi.TAG
+
+/**
+ * A bi-directional cursor reader. Reads URI from the [cursor] starting from the given [startPos],
+ * filters items by [predicate].
+ */
+class CursorUriReader(
+ private val cursor: Cursor,
+ startPos: Int,
+ private val pageSize: Int,
+ private val predicate: (Uri) -> Boolean,
+) : PayloadToggleInteractor.CursorReader {
+ override val count = cursor.count
+ // Unread ranges are:
+ // - left: [0, leftPos);
+ // - right: [rightPos, count)
+ // i.e. read range is: [leftPos, rightPos)
+ private var rightPos = startPos.coerceIn(0, count)
+ private var leftPos = rightPos
+
+ override val hasMoreBefore
+ get() = leftPos > 0
+
+ override val hasMoreAfter
+ get() = rightPos < count
+
+ override fun readPageAfter(): SparseArray<Uri> {
+ if (!hasMoreAfter) return SparseArray()
+ if (!cursor.moveToPosition(rightPos)) {
+ rightPos = count
+ Log.w(TAG, "Failed to move the cursor to position $rightPos, stop reading the cursor")
+ return SparseArray()
+ }
+ val result = SparseArray<Uri>(pageSize)
+ do {
+ cursor
+ .getString(0)
+ ?.let(Uri::parse)
+ ?.takeIf { predicate(it) }
+ ?.let { uri -> result.append(rightPos, uri) }
+ rightPos++
+ } while (result.size() < pageSize && cursor.moveToNext())
+ maybeCloseCursor()
+ return result
+ }
+
+ override fun readPageBefore(): SparseArray<Uri> {
+ if (!hasMoreBefore) return SparseArray()
+ val startPos = maxOf(0, leftPos - pageSize)
+ if (!cursor.moveToPosition(startPos)) {
+ leftPos = 0
+ Log.w(TAG, "Failed to move the cursor to position $startPos, stop reading cursor")
+ return SparseArray()
+ }
+ val result = SparseArray<Uri>(leftPos - startPos)
+ for (pos in startPos until leftPos) {
+ cursor
+ .getString(0)
+ ?.let(Uri::parse)
+ ?.takeIf { predicate(it) }
+ ?.let { uri -> result.append(pos, uri) }
+ if (!cursor.moveToNext()) break
+ }
+ leftPos = startPos
+ maybeCloseCursor()
+ return result
+ }
+
+ private fun maybeCloseCursor() {
+ if (!hasMoreBefore && !hasMoreAfter) {
+ close()
+ }
+ }
+
+ override fun close() {
+ cursor.close()
+ }
+
+ companion object {
+ suspend fun createCursorReader(
+ contentResolver: ContentInterface,
+ uri: Uri,
+ chooserIntent: Intent
+ ): CursorUriReader {
+ val cancellationSignal = CancellationSignal()
+ val cursor =
+ try {
+ coroutineScope {
+ runCatching {
+ contentResolver.query(
+ uri,
+ arrayOf(Columns.URI),
+ Bundle().apply {
+ putParcelable(Intent.EXTRA_INTENT, chooserIntent)
+ },
+ cancellationSignal
+ )
+ }
+ .getOrNull()
+ ?: MatrixCursor(arrayOf(Columns.URI))
+ }
+ } catch (e: CancellationException) {
+ cancellationSignal.cancel()
+ throw e
+ }
+ return CursorUriReader(
+ cursor,
+ cursor.extras?.getInt(CursorExtraKeys.POSITION, 0) ?: 0,
+ 128,
+ ) {
+ it.authority != uri.authority
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
index 89e7e528..d4eea8b9 100644
--- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java
@@ -43,15 +43,20 @@ class FileContentPreviewUi extends ContentPreviewUi {
private final ChooserContentPreviewUi.ActionFactory mActionFactory;
private final HeadlineGenerator mHeadlineGenerator;
@Nullable
+ private final CharSequence mMetadata;
+ @Nullable
private ViewGroup mContentPreview = null;
FileContentPreviewUi(
int fileCount,
ChooserContentPreviewUi.ActionFactory actionFactory,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ @Nullable CharSequence metadata
+ ) {
mFileCount = fileCount;
mActionFactory = actionFactory;
mHeadlineGenerator = headlineGenerator;
+ mMetadata = metadata;
}
@Override
@@ -91,6 +96,7 @@ class FileContentPreviewUi extends ContentPreviewUi {
inflateHeadline(headlineViewParent);
displayHeadline(headlineViewParent, mHeadlineGenerator.getFilesHeadline(mFileCount));
+ displayMetadata(headlineViewParent, mMetadata);
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 78fc6586..6832c5c4 100644
--- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
@@ -57,6 +57,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
private final ImageLoader mImageLoader;
private final MimeTypeClassifier mTypeClassifier;
private final HeadlineGenerator mHeadlineGenerator;
+ @Nullable
+ private final CharSequence mMetadata;
private final boolean mIsSingleImage;
private final int mFileCount;
private ViewGroup mContentPreviewView;
@@ -78,7 +80,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
MimeTypeClassifier typeClassifier,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ @Nullable CharSequence metadata) {
if (isSingleImage && fileCount != 1) {
throw new IllegalArgumentException(
"fileCount = " + fileCount + " and isSingleImage = true");
@@ -92,6 +95,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
mImageLoader = imageLoader;
mTypeClassifier = typeClassifier;
mHeadlineGenerator = headlineGenerator;
+ mMetadata = metadata;
}
@Override
@@ -204,6 +208,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
}
displayHeadline(headlineView, headline);
+ displayMetadata(headlineView, mMetadata);
}
private void prepareTextPreview(
diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt
index 5f87c924..21308341 100644
--- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt
+++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt
@@ -17,12 +17,14 @@
package com.android.intentresolver.contentpreview
/**
- * 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.
*/
interface HeadlineGenerator {
fun getTextHeadline(text: CharSequence): String
+ fun getAlbumHeadline(): String
+
fun getImagesWithTextHeadline(text: CharSequence, count: Int): String
fun getVideosWithTextHeadline(text: CharSequence, count: Int): String
diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
index ef1e55d8..6e126822 100644
--- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
+++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
@@ -34,6 +34,10 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator {
)
}
+ override fun getAlbumHeadline(): String {
+ return context.getString(R.string.sharing_album)
+ }
+
override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String {
return getPluralString(
getTemplateResource(
diff --git a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt b/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt
new file mode 100644
index 00000000..1cc1a6a6
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.service.chooser.ChooserAction
+import com.android.intentresolver.widget.ActionRow
+import kotlinx.coroutines.flow.Flow
+
+interface MutableActionFactory {
+ /** A flow of custom actions */
+ val customActionsFlow: Flow<List<ActionRow.Action>>
+
+ /** Update custom actions */
+ fun updateCustomActions(actions: List<ChooserAction>)
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt
new file mode 100644
index 00000000..eda5c4ca
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt
@@ -0,0 +1,382 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.Intent
+import android.content.IntentSender
+import android.net.Uri
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserTarget
+import android.util.Log
+import android.util.SparseArray
+import java.io.Closeable
+import java.util.LinkedList
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.channels.BufferOverflow.DROP_LATEST
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+private const val TAG = "PayloadToggleInteractor"
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class PayloadToggleInteractor(
+ // TODO: a single-thread dispatcher is currently expected. iterate on the synchronization logic.
+ private val scope: CoroutineScope,
+ private val initiallySharedUris: List<Uri>,
+ private val focusedUriIdx: Int,
+ private val mimeTypeClassifier: MimeTypeClassifier,
+ private val cursorReaderProvider: suspend () -> CursorReader,
+ private val uriMetadataReader: (Uri) -> FileInfo,
+ private val targetIntentModifier: (List<Item>) -> Intent,
+ private val selectionCallback: (Intent) -> ShareouselUpdate?,
+) {
+ private var cursorDataRef = CompletableDeferred<CursorData?>()
+ private val records = LinkedList<Record>()
+ private val prevPageLoadingGate = AtomicBoolean(true)
+ private val nextPageLoadingGate = AtomicBoolean(true)
+ private val notifySelectionJobRef = AtomicReference<Job?>()
+ private val emptyState =
+ State(
+ emptyList(),
+ hasMoreItemsBefore = false,
+ hasMoreItemsAfter = false,
+ allowSelectionChange = false
+ )
+
+ private val stateFlowSource = MutableStateFlow(emptyState)
+
+ val customActions =
+ MutableSharedFlow<List<ChooserAction>>(replay = 1, onBufferOverflow = DROP_LATEST)
+
+ val stateFlow: Flow<State>
+ get() = stateFlowSource.filter { it !== emptyState }
+
+ val targetPosition: Flow<Int> = stateFlow.map { it.targetPos }
+ val previewKeys: Flow<List<Item>> = stateFlow.map { it.items }
+
+ fun getKey(item: Any): Int = (item as Item).key
+
+ fun selected(key: Item): Flow<Boolean> = (key as Record).isSelected
+
+ fun previewUri(key: Item): Flow<Uri?> = flow { emit(key.previewUri) }
+
+ fun previewInteractor(key: Any): PayloadTogglePreviewInteractor {
+ val state = stateFlowSource.value
+ if (state === emptyState) {
+ Log.wtf(TAG, "Requesting item preview before any item has been published")
+ } else {
+ if (state.hasMoreItemsBefore && key === state.items.firstOrNull()) {
+ loadMorePreviousItems()
+ }
+ if (state.hasMoreItemsAfter && key == state.items.lastOrNull()) {
+ loadMoreNextItems()
+ }
+ }
+ return PayloadTogglePreviewInteractor(key as Item, this)
+ }
+
+ init {
+ scope
+ .launch { awaitCancellation() }
+ .invokeOnCompletion {
+ cursorDataRef.cancel()
+ runCatching {
+ if (cursorDataRef.isCompleted && !cursorDataRef.isCancelled) {
+ cursorDataRef.getCompleted()
+ } else {
+ null
+ }
+ }
+ .getOrNull()
+ ?.reader
+ ?.close()
+ }
+ }
+
+ fun start() {
+ scope.launch {
+ val cursorReader = cursorReaderProvider()
+ val selectedItems =
+ initiallySharedUris.map { uri ->
+ val fileInfo = uriMetadataReader(uri)
+ Record(
+ 0, // artificial key for the pending record, it should not be used anywhere
+ uri,
+ fileInfo.previewUri,
+ fileInfo.mimeType,
+ )
+ }
+ val cursorData =
+ CursorData(
+ cursorReader,
+ SelectionTracker(selectedItems, focusedUriIdx, cursorReader.count) { uri },
+ )
+ if (cursorDataRef.complete(cursorData)) {
+ doLoadMorePreviousItems()
+ val startPos = records.size
+ doLoadMoreNextItems()
+ prevPageLoadingGate.set(false)
+ nextPageLoadingGate.set(false)
+ publishSnapshot(startPos)
+ } else {
+ cursorReader.close()
+ }
+ }
+ }
+
+ fun loadMorePreviousItems() {
+ invokeAsyncIfNotRunning(prevPageLoadingGate) {
+ doLoadMorePreviousItems()
+ publishSnapshot()
+ }
+ }
+
+ fun loadMoreNextItems() {
+ invokeAsyncIfNotRunning(nextPageLoadingGate) {
+ doLoadMoreNextItems()
+ publishSnapshot()
+ }
+ }
+
+ fun setSelected(item: Item, isSelected: Boolean) {
+ val record = item as Record
+ scope.launch {
+ val (_, selectionTracker) = waitForCursorData() ?: return@launch
+ if (selectionTracker.setItemSelection(record.key, record, isSelected)) {
+ val targetIntent = targetIntentModifier(selectionTracker.getSelection())
+ val newJob = scope.launch { notifySelectionChanged(targetIntent) }
+ notifySelectionJobRef.getAndSet(newJob)?.cancel()
+ record.isSelected.value = selectionTracker.isItemSelected(record.key)
+ }
+ }
+ }
+
+ private fun invokeAsyncIfNotRunning(guardingFlag: AtomicBoolean, block: suspend () -> Unit) {
+ if (guardingFlag.compareAndSet(false, true)) {
+ scope.launch { block() }.invokeOnCompletion { guardingFlag.set(false) }
+ }
+ }
+
+ private suspend fun doLoadMorePreviousItems() {
+ val (reader, selectionTracker) = waitForCursorData() ?: return
+ if (!reader.hasMoreBefore) return
+
+ val newItems = reader.readPageBefore().toItems()
+ selectionTracker.onStartItemsAdded(newItems)
+ for (i in newItems.size() - 1 downTo 0) {
+ records.add(
+ 0,
+ (newItems.valueAt(i) as Record).apply {
+ isSelected.value = selectionTracker.isItemSelected(key)
+ }
+ )
+ }
+ if (!reader.hasMoreBefore && !reader.hasMoreAfter) {
+ val pendingItems = selectionTracker.getPendingItems()
+ val newRecords =
+ pendingItems.foldIndexed(SparseArray<Item>()) { idx, acc, item ->
+ assert(item is Record) { "Unexpected pending item type: ${item.javaClass}" }
+ val rec = item as Record
+ val key = idx - pendingItems.size
+ acc.append(
+ key,
+ Record(
+ key,
+ rec.uri,
+ rec.previewUri,
+ rec.mimeType,
+ rec.mimeType?.mimeTypeToItemType() ?: ItemType.File
+ )
+ )
+ acc
+ }
+
+ selectionTracker.onStartItemsAdded(newRecords)
+ for (i in (newRecords.size() - 1) downTo 0) {
+ records.add(0, (newRecords.valueAt(i) as Record).apply { isSelected.value = true })
+ }
+ }
+ }
+
+ private suspend fun doLoadMoreNextItems() {
+ val (reader, selectionTracker) = waitForCursorData() ?: return
+ if (!reader.hasMoreAfter) return
+
+ val newItems = reader.readPageAfter().toItems()
+ selectionTracker.onEndItemsAdded(newItems)
+ for (i in 0 until newItems.size()) {
+ val key = newItems.keyAt(i)
+ records.add(
+ (newItems.valueAt(i) as Record).apply {
+ isSelected.value = selectionTracker.isItemSelected(key)
+ }
+ )
+ }
+ if (!reader.hasMoreBefore && !reader.hasMoreAfter) {
+ val items =
+ selectionTracker.getPendingItems().let { items ->
+ items.foldIndexed(SparseArray<Item>(items.size)) { i, acc, item ->
+ val key = reader.count + i
+ val record = item as Record
+ acc.append(
+ key,
+ Record(key, record.uri, record.previewUri, record.mimeType, record.type)
+ )
+ acc
+ }
+ }
+ selectionTracker.onEndItemsAdded(items)
+ for (i in 0 until items.size()) {
+ records.add((items.valueAt(i) as Record).apply { isSelected.value = true })
+ }
+ }
+ }
+
+ private fun SparseArray<Uri>.toItems(): SparseArray<Item> {
+ val items = SparseArray<Item>(size())
+ for (i in 0 until size()) {
+ val key = keyAt(i)
+ val uri = valueAt(i)
+ val fileInfo = uriMetadataReader(uri)
+ items.append(
+ key,
+ Record(
+ key,
+ uri,
+ fileInfo.previewUri,
+ fileInfo.mimeType,
+ fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File
+ )
+ )
+ }
+ return items
+ }
+
+ private suspend fun waitForCursorData() = cursorDataRef.await()
+
+ private fun notifySelectionChanged(targetIntent: Intent) {
+ selectionCallback(targetIntent)?.customActions?.let { customActions.tryEmit(it) }
+ }
+
+ private suspend fun publishSnapshot(startPos: Int = -1) {
+ val (reader, _) = waitForCursorData() ?: return
+ // TODO: publish a view into the list as it can only grow on each side thus a view won't be
+ // invalidated
+ val items = ArrayList<Item>(records)
+ stateFlowSource.emit(
+ State(
+ items,
+ reader.hasMoreBefore,
+ reader.hasMoreAfter,
+ allowSelectionChange = true,
+ targetPos = startPos,
+ )
+ )
+ }
+
+ private fun String.mimeTypeToItemType(): ItemType =
+ when {
+ mimeTypeClassifier.isImageType(this) -> ItemType.Image
+ mimeTypeClassifier.isVideoType(this) -> ItemType.Video
+ else -> ItemType.File
+ }
+
+ class State(
+ val items: List<Item>,
+ val hasMoreItemsBefore: Boolean,
+ val hasMoreItemsAfter: Boolean,
+ val allowSelectionChange: Boolean,
+ val targetPos: Int = -1,
+ )
+
+ sealed interface Item {
+ val key: Int
+ val uri: Uri
+ val previewUri: Uri?
+ val mimeType: String?
+ val type: ItemType
+ }
+
+ enum class ItemType {
+ Image,
+ Video,
+ File,
+ }
+
+ private class Record(
+ override val key: Int,
+ override val uri: Uri,
+ override val previewUri: Uri? = uri,
+ override val mimeType: String?,
+ override val type: ItemType = ItemType.Image,
+ ) : Item {
+ val isSelected = MutableStateFlow(false)
+ }
+
+ data class ShareouselUpdate(
+ // for all properties, null value means no change
+ val customActions: List<ChooserAction>? = null,
+ val modifyShareAction: ChooserAction? = null,
+ val alternateIntents: List<Intent>? = null,
+ val callerTargets: List<ChooserTarget>? = null,
+ val refinementIntentSender: IntentSender? = null,
+ )
+
+ private data class CursorData(
+ val reader: CursorReader,
+ val selectionTracker: SelectionTracker<Item>,
+ )
+
+ interface CursorReader : Closeable {
+ val count: Int
+ val hasMoreBefore: Boolean
+ val hasMoreAfter: Boolean
+
+ fun readPageAfter(): SparseArray<Uri>
+
+ fun readPageBefore(): SparseArray<Uri>
+ }
+}
+
+class PayloadTogglePreviewInteractor(
+ private val item: PayloadToggleInteractor.Item,
+ private val interactor: PayloadToggleInteractor,
+) {
+ fun setSelected(selected: Boolean) {
+ interactor.setSelected(item, selected)
+ }
+
+ val previewUri: Flow<Uri?>
+ get() = interactor.previewUri(item)
+
+ val selected: Flow<Boolean>
+ get() = interactor.selected(item)
+
+ val key
+ get() = item.key
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
index 38918d79..96bb8258 100644
--- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
@@ -18,7 +18,6 @@ package com.android.intentresolver.contentpreview
import android.content.ContentInterface
import android.content.Intent
-import android.database.Cursor
import android.media.MediaMetadata
import android.net.Uri
import android.provider.DocumentsContract
@@ -31,6 +30,7 @@ import androidx.annotation.OpenForTesting
import androidx.annotation.VisibleForTesting
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_PAYLOAD_SELECTION
import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT
import com.android.intentresolver.measurements.runTracing
import com.android.intentresolver.util.ownedByCurrentUser
@@ -74,7 +74,11 @@ open class PreviewDataProvider
constructor(
private val scope: CoroutineScope,
private val targetIntent: Intent,
+ private val additionalContentUri: Uri?,
private val contentResolver: ContentInterface,
+ // TODO: replace with the ChooserServiceFlags ref when PreviewViewModel dependencies are sorted
+ // out
+ private val isPayloadTogglingEnabled: Boolean,
private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier,
) {
@@ -100,6 +104,9 @@ constructor(
open val uriCount: Int
get() = records.size
+ val uris: List<Uri>
+ get() = records.map { it.uri }
+
/**
* Returns a [Flow] of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] and
* [FileInfo.previewUri] set (a data projection tailored for the image preview UI).
@@ -122,6 +129,9 @@ constructor(
* IMAGE, FILE, TEXT. */
if (!targetIntent.isSend || records.isEmpty()) {
CONTENT_PREVIEW_TEXT
+ } else if (isPayloadTogglingEnabled && shouldShowPayloadSelection()) {
+ // TODO: replace with the proper flags injection
+ CONTENT_PREVIEW_PAYLOAD_SELECTION
} else {
try {
runBlocking(scope.coroutineContext) {
@@ -140,6 +150,22 @@ constructor(
}
}
+ private fun shouldShowPayloadSelection(): Boolean {
+ val extraContentUri = additionalContentUri ?: return false
+ return runCatching {
+ val authority = extraContentUri.authority
+ records.firstOrNull { authority == it.uri.authority } == null
+ }
+ .onFailure {
+ Log.w(
+ ContentPreviewUi.TAG,
+ "Failed to check URI authorities; no payload toggling",
+ it
+ )
+ }
+ .getOrDefault(false)
+ }
+
/**
* The first shared URI's metadata. This call wait's for the data to be loaded and falls back to
* a crude value if the data is not loaded within a time limit.
@@ -250,8 +276,7 @@ constructor(
val isImageType: Boolean
get() = typeClassifier.isImageType(mimeType)
val supportsImageType: Boolean by lazy {
- contentResolver.getStreamTypesSafe(uri)?.firstOrNull(typeClassifier::isImageType) !=
- null
+ contentResolver.getStreamTypesSafe(uri).firstOrNull(typeClassifier::isImageType) != null
}
val supportsThumbnail: Boolean
get() = query.supportsThumbnail
@@ -263,7 +288,8 @@ constructor(
private val query by lazy { readQueryResult() }
private fun readQueryResult(): QueryResult =
- contentResolver.querySafe(uri)?.use { cursor ->
+ // TODO: rewrite using methods from UiMetadataHelpers.kt
+ contentResolver.querySafe(uri, METADATA_COLUMNS)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
var flagColIdx = -1
@@ -344,51 +370,3 @@ private fun getFileName(uri: Uri): String {
fileName.substring(index + 1)
}
}
-
-private fun ContentInterface.getTypeSafe(uri: Uri): String? =
- runTracing("getType") {
- try {
- getType(uri)
- } catch (e: SecurityException) {
- logProviderPermissionWarning(uri, "mime type")
- null
- } catch (t: Throwable) {
- Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
- null
- }
- }
-
-private fun ContentInterface.getStreamTypesSafe(uri: Uri): Array<String>? =
- runTracing("getStreamTypes") {
- try {
- getStreamTypes(uri, "*/*")
- } catch (e: SecurityException) {
- logProviderPermissionWarning(uri, "stream types")
- null
- } catch (t: Throwable) {
- Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t)
- null
- }
- }
-
-private fun ContentInterface.querySafe(uri: Uri): Cursor? =
- runTracing("query") {
- try {
- query(uri, METADATA_COLUMNS, null, null)
- } catch (e: SecurityException) {
- logProviderPermissionWarning(uri, "metadata")
- null
- } catch (t: Throwable) {
- Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
- null
- }
- }
-
-private fun logProviderPermissionWarning(uri: Uri, dataName: String) {
- // The ContentResolver already logs the exception. Log something more informative.
- Log.w(
- ContentPreviewUi.TAG,
- "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" +
- " ensure that the sharesheet is given permission."
- )
-}
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
index 6350756e..d694c6ff 100644
--- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
@@ -17,58 +17,108 @@
package com.android.intentresolver.contentpreview
import android.app.Application
+import android.content.ContentResolver
import android.content.Intent
+import android.net.Uri
import androidx.annotation.MainThread
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
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 java.util.concurrent.Executors
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.plus
-/** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */
-@HiltViewModel
-class PreviewViewModel
-@Inject
-constructor(
- private val application: Application,
+/** A view model for the preview logic */
+class PreviewViewModel(
+ private val contentResolver: ContentResolver,
+ // TODO: inject ImageLoader instead
+ private val thumbnailSize: Int,
@Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : BasePreviewViewModel() {
- private var previewDataProvider: PreviewDataProvider? = null
- private var imageLoader: ImagePreviewImageLoader? = null
+ private var targetIntent: Intent? = null
+ private var chooserIntent: Intent? = null
+ private var additionalContentUri: Uri? = null
+ private var focusedItemIdx: Int = 0
+ private var isPayloadTogglingEnabled = false
- @MainThread
- override fun createOrReuseProvider(
- targetIntent: Intent
- ): PreviewDataProvider =
- previewDataProvider
- ?: PreviewDataProvider(
- viewModelScope + dispatcher,
- targetIntent,
- application.contentResolver
- )
- .also { previewDataProvider = it }
+ override val previewDataProvider by lazy {
+ val targetIntent = requireNotNull(this.targetIntent) { "Not initialized" }
+ PreviewDataProvider(
+ viewModelScope + dispatcher,
+ targetIntent,
+ additionalContentUri,
+ contentResolver,
+ isPayloadTogglingEnabled,
+ )
+ }
+
+ override val imageLoader by lazy {
+ ImagePreviewImageLoader(
+ viewModelScope + dispatcher,
+ thumbnailSize,
+ contentResolver,
+ cacheSize = 16
+ )
+ }
+
+ override val payloadToggleInteractor: PayloadToggleInteractor? by lazy {
+ val targetIntent = requireNotNull(targetIntent) { "Not initialized" }
+ // TODO: replace with flags injection
+ if (!isPayloadTogglingEnabled) return@lazy null
+ createPayloadToggleInteractor(
+ additionalContentUri ?: return@lazy null,
+ targetIntent,
+ chooserIntent ?: return@lazy null,
+ )
+ .apply { start() }
+ }
+ // TODO: make the view model injectable and inject these dependencies instead
@MainThread
- override fun createOrReuseImageLoader(): ImageLoader =
- imageLoader
- ?: ImagePreviewImageLoader(
- viewModelScope + dispatcher,
- thumbnailSize =
- application.resources.getDimensionPixelSize(
- R.dimen.chooser_preview_image_max_dimen
- ),
- application.contentResolver,
- cacheSize = 16
+ override fun init(
+ targetIntent: Intent,
+ chooserIntent: Intent,
+ additionalContentUri: Uri?,
+ focusedItemIdx: Int,
+ isPayloadTogglingEnabled: Boolean,
+ ) {
+ if (this.targetIntent != null) return
+ this.targetIntent = targetIntent
+ this.chooserIntent = chooserIntent
+ this.additionalContentUri = additionalContentUri
+ this.focusedItemIdx = focusedItemIdx
+ this.isPayloadTogglingEnabled = isPayloadTogglingEnabled
+ }
+
+ private fun createPayloadToggleInteractor(
+ contentProviderUri: Uri,
+ targetIntent: Intent,
+ chooserIntent: Intent,
+ ): PayloadToggleInteractor {
+ return PayloadToggleInteractor(
+ // TODO: update PayloadToggleInteractor to support multiple threads
+ viewModelScope + Executors.newSingleThreadScheduledExecutor().asCoroutineDispatcher(),
+ previewDataProvider.uris,
+ maxOf(0, minOf(focusedItemIdx, previewDataProvider.uriCount - 1)),
+ DefaultMimeTypeClassifier,
+ {
+ CursorUriReader.createCursorReader(
+ contentResolver,
+ contentProviderUri,
+ chooserIntent
)
- .also { imageLoader = it }
+ },
+ UriMetadataReader(contentResolver, DefaultMimeTypeClassifier),
+ TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }),
+ SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver)
+ )
+ }
companion object {
val Factory: ViewModelProvider.Factory =
@@ -77,7 +127,16 @@ constructor(
override fun <T : ViewModel> create(
modelClass: Class<T>,
extras: CreationExtras
- ): T = PreviewViewModel(checkNotNull(extras[APPLICATION_KEY])) as T
+ ): T {
+ val application: Application = checkNotNull(extras[APPLICATION_KEY])
+ return PreviewViewModel(
+ application.contentResolver,
+ application.resources.getDimensionPixelSize(
+ R.dimen.chooser_preview_image_max_dimen
+ )
+ )
+ as T
+ }
}
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt
new file mode 100644
index 00000000..6b33e1cd
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.ContentInterface
+import android.content.Intent
+import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION
+import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_TARGETS
+import android.content.Intent.EXTRA_INTENT
+import android.content.IntentSender
+import android.net.Uri
+import android.os.Bundle
+import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserTarget
+import com.android.intentresolver.contentpreview.PayloadToggleInteractor.ShareouselUpdate
+import com.android.intentresolver.v2.ui.viewmodel.readAlternateIntents
+import com.android.intentresolver.v2.ui.viewmodel.readChooserActions
+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.log
+import com.android.intentresolver.v2.validation.types.array
+import com.android.intentresolver.v2.validation.types.value
+import com.android.intentresolver.v2.validation.validateFrom
+
+private const val TAG = "SelectionChangeCallback"
+
+/**
+ * Encapsulates payload change callback invocation to the sharing app; handles callback arguments
+ * and result format mapping.
+ */
+class SelectionChangeCallback(
+ private val uri: Uri,
+ private val chooserIntent: Intent,
+ private val contentResolver: ContentInterface,
+) : (Intent) -> ShareouselUpdate? {
+ fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? =
+ contentResolver
+ .call(
+ requireNotNull(uri.authority) { "URI authority can not be null" },
+ ON_SELECTION_CHANGED,
+ uri.toString(),
+ Bundle().apply {
+ putParcelable(
+ EXTRA_INTENT,
+ Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) }
+ )
+ }
+ )
+ ?.let { bundle ->
+ return when (val result = readCallbackResponse(bundle)) {
+ is Valid -> result.value
+ is Invalid -> {
+ result.errors.forEach { it.log(TAG) }
+ null
+ }
+ }
+ }
+
+ override fun invoke(targetIntent: Intent) = onSelectionChanged(targetIntent)
+
+ private fun readCallbackResponse(bundle: Bundle): ValidationResult<ShareouselUpdate> {
+ return validateFrom(bundle::get) {
+ val customActions = readChooserActions()
+ val modifyShareAction =
+ optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION))
+ val alternateIntents = readAlternateIntents()
+ val callerTargets = optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS))
+ val refinementIntentSender =
+ optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER))
+
+ ShareouselUpdate(
+ customActions,
+ modifyShareAction,
+ alternateIntents,
+ callerTargets,
+ refinementIntentSender,
+ )
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt
new file mode 100644
index 00000000..c9431731
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.net.Uri
+import android.util.SparseArray
+import android.util.SparseIntArray
+import androidx.core.util.containsKey
+import androidx.core.util.isNotEmpty
+
+/**
+ * Tracks selected items (including those that has not been read frm the cursor) and their relative
+ * order.
+ */
+class SelectionTracker<Item>(
+ selectedItems: List<Item>,
+ private val focusedItemIdx: Int,
+ private val cursorCount: Int,
+ private val getUri: Item.() -> Uri,
+) {
+ /** Contains selected items keys. */
+ private val selections = SparseArray<Item>(selectedItems.size)
+
+ /**
+ * A set of initially selected items that has not yet been observed by the lazy read of the
+ * cursor and thus has unknown key (cursor position). Initially, all [selectedItems] are put in
+ * this map with items at the index less than [focusedItemIdx] with negative keys (to the left
+ * of all cursor items) and items at the index more or equal to [focusedItemIdx] with keys more
+ * or equal to [cursorCount] (to the right of all cursor items) in their relative order. Upon
+ * reading the cursor, [onEndItemsAdded]/[onStartItemsAdded], all pending items from that
+ * collection in the corresponding direction get their key assigned and gets removed from the
+ * map. Items that were missing from the cursor get removed from the map by
+ * [getPendingItems] + [onStartItemsAdded]/[onEndItemsAdded] combination.
+ */
+ private val pendingKeys = HashMap<Uri, SparseIntArray>()
+
+ init {
+ selectedItems.forEachIndexed { i, item ->
+ // all items before focusedItemIdx gets "positioned" before all the cursor items
+ // and all the reset after all the cursor items in their relative order.
+ // Also see the comments to pendingKeys property.
+ val key =
+ if (i < focusedItemIdx) {
+ i - focusedItemIdx
+ } else {
+ i + cursorCount - focusedItemIdx
+ }
+ selections.append(key, item)
+ pendingKeys.getOrPut(item.getUri()) { SparseIntArray(1) }.append(key, key)
+ }
+ }
+
+ /** Update selections based on the set of items read from the end of the cursor */
+ fun onEndItemsAdded(items: SparseArray<Item>) {
+ for (i in 0 until items.size()) {
+ val item = items.valueAt(i)
+ pendingKeys[item.getUri()]
+ // if only one pending (unmatched) item with this URI is left, removed this URI
+ ?.also {
+ if (it.size() <= 1) {
+ pendingKeys.remove(item.getUri())
+ }
+ }
+ // a safeguard, we should not observe empty arrays at this point
+ ?.takeIf { it.isNotEmpty() }
+ // pick a matching pending items from the right side
+ ?.let { pendingUriPositions ->
+ val key = items.keyAt(i)
+ val insertPos =
+ pendingUriPositions
+ .findBestKeyPosition(key)
+ .coerceIn(0, pendingUriPositions.size() - 1)
+ // select next pending item from the right, if not such item exists then
+ // the data is inconsistent and we pick the closes one from the left
+ val keyPlaceholder = pendingUriPositions.keyAt(insertPos)
+ pendingUriPositions.removeAt(insertPos)
+ selections.remove(keyPlaceholder)
+ selections[key] = item
+ }
+ }
+ }
+
+ /** Update selections based on the set of items read from the head of the cursor */
+ fun onStartItemsAdded(items: SparseArray<Item>) {
+ for (i in (items.size() - 1) downTo 0) {
+ val item = items.valueAt(i)
+ pendingKeys[item.getUri()]
+ // if only one pending (unmatched) item with this URI is left, removed this URI
+ ?.also {
+ if (it.size() <= 1) {
+ pendingKeys.remove(item.getUri())
+ }
+ }
+ // a safeguard, we should not observe empty arrays at this point
+ ?.takeIf { it.isNotEmpty() }
+ // pick a matching pending items from the left side
+ ?.let { pendingUriPositions ->
+ val key = items.keyAt(i)
+ val insertPos =
+ pendingUriPositions
+ .findBestKeyPosition(key)
+ .coerceIn(1, pendingUriPositions.size())
+ // select next pending item from the left, if not such item exists then
+ // the data is inconsistent and we pick the closes one from the right
+ val keyPlaceholder = pendingUriPositions.keyAt(insertPos - 1)
+ pendingUriPositions.removeAt(insertPos - 1)
+ selections.remove(keyPlaceholder)
+ selections[key] = item
+ }
+ }
+ }
+
+ /** Updated selection status for the given item */
+ fun setItemSelection(key: Int, item: Item, isSelected: Boolean): Boolean {
+ val idx = selections.indexOfKey(key)
+ if (isSelected && idx < 0) {
+ selections[key] = item
+ return true
+ }
+ if (!isSelected && idx >= 0 && selections.size() > 1) {
+ selections.removeAt(idx)
+ return true
+ }
+ return false
+ }
+
+ /** Return selection status for the given item */
+ fun isItemSelected(key: Int): Boolean = selections.containsKey(key)
+
+ fun getSelection(): List<Item> =
+ buildList(selections.size()) {
+ for (i in 0 until selections.size()) {
+ add(selections.valueAt(i))
+ }
+ }
+
+ /** Return all selected items that has not yet been read from the cursor */
+ fun getPendingItems(): List<Item> =
+ if (pendingKeys.isEmpty()) {
+ emptyList()
+ } else {
+ buildList {
+ for (i in 0 until selections.size()) {
+ val item = selections.valueAt(i) ?: continue
+ if (isPending(item, selections.keyAt(i))) {
+ add(item)
+ }
+ }
+ }
+ }
+
+ private fun isPending(item: Item, key: Int): Boolean {
+ val keys = pendingKeys[item.getUri()] ?: return false
+ return keys.containsKey(key)
+ }
+
+ private fun SparseIntArray.findBestKeyPosition(key: Int): Int =
+ // undocumented, but indexOfKey behaves in the same was as
+ // java.util.Collections#binarySearch()
+ indexOfKey(key).let { if (it < 0) it.inv() else it }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
new file mode 100644
index 00000000..82c09986
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.intentresolver.contentpreview
+
+import android.content.res.Resources
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.annotation.VisibleForTesting
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.dimensionResource
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.android.intentresolver.R
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
+import com.android.intentresolver.contentpreview.shareousel.ui.composable.Shareousel
+import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel
+import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.toShareouselViewModel
+
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+class ShareouselContentPreviewUi(
+ private val actionFactory: ActionFactory,
+) : ContentPreviewUi() {
+
+ override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE
+
+ override fun display(
+ resources: Resources,
+ layoutInflater: LayoutInflater,
+ parent: ViewGroup,
+ headlineViewParent: View?,
+ ): ViewGroup {
+ return displayInternal(parent, headlineViewParent).also { layout ->
+ displayModifyShareAction(headlineViewParent ?: layout, actionFactory)
+ }
+ }
+
+ private fun displayInternal(
+ parent: ViewGroup,
+ headlineViewParent: View?,
+ ): ViewGroup {
+ if (headlineViewParent != null) {
+ inflateHeadline(headlineViewParent)
+ }
+ val composeView =
+ ComposeView(parent.context).apply {
+ setContent {
+ val vm: BasePreviewViewModel = viewModel()
+ val interactor =
+ requireNotNull(vm.payloadToggleInteractor) { "Should not be null" }
+
+ var viewModel by remember { mutableStateOf<ShareouselViewModel?>(null) }
+ LaunchedEffect(Unit) {
+ viewModel =
+ interactor.toShareouselViewModel(
+ vm.imageLoader,
+ actionFactory,
+ vm.viewModelScope
+ )
+ }
+
+ headlineViewParent?.let {
+ viewModel?.let { viewModel ->
+ LaunchedEffect(viewModel) {
+ viewModel.headline.collect { headline ->
+ headlineViewParent
+ .findViewById<TextView>(R.id.headline)
+ ?.apply {
+ if (headline.isNotBlank()) {
+ text = headline
+ visibility = View.VISIBLE
+ } else {
+ visibility = View.GONE
+ }
+ }
+ }
+ }
+ }
+ }
+
+ viewModel?.let { viewModel ->
+ MaterialTheme(
+ colorScheme =
+ if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ },
+ ) {
+ Shareousel(viewModel = viewModel)
+ }
+ }
+ ?: run {
+ Spacer(
+ Modifier.height(
+ dimensionResource(R.dimen.chooser_preview_image_height_tall)
+ )
+ )
+ }
+ }
+ }
+ return composeView
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt
new file mode 100644
index 00000000..58da5bc4
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.ClipData
+import android.content.ClipDescription.compareMimeTypes
+import android.content.Intent
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.ACTION_SEND_MULTIPLE
+import android.content.Intent.EXTRA_STREAM
+import android.net.Uri
+
+/** Modifies target intent based on current payload selection. */
+class TargetIntentModifier<Item>(
+ private val originalTargetIntent: Intent,
+ private val getUri: Item.() -> Uri,
+ private val getMimeType: Item.() -> String?,
+) : (List<Item>) -> Intent {
+ fun onSelectionChanged(selection: List<Item>): Intent {
+ val uris = ArrayList<Uri>(selection.size)
+ var targetMimeType: String? = null
+ for (item in selection) {
+ targetMimeType = updateMimeType(item.getMimeType(), targetMimeType)
+ uris.add(item.getUri())
+ }
+ val action = if (uris.size == 1) ACTION_SEND else ACTION_SEND_MULTIPLE
+ return Intent(originalTargetIntent).apply {
+ this.action = action
+ this.type = targetMimeType
+ if (action == ACTION_SEND) {
+ putExtra(EXTRA_STREAM, uris[0])
+ } else {
+ putParcelableArrayListExtra(EXTRA_STREAM, uris)
+ }
+ if (uris.isNotEmpty()) {
+ clipData =
+ ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also {
+ for (i in 1 until uris.size) {
+ it.addItem(ClipData.Item(uris[i]))
+ }
+ }
+ }
+ }
+ }
+
+ private fun updateMimeType(itemMimeType: String?, unitedMimeType: String?): String {
+ itemMimeType ?: return "*/*"
+ unitedMimeType ?: return itemMimeType
+ if (compareMimeTypes(itemMimeType, unitedMimeType)) return unitedMimeType
+ val slashIdx = unitedMimeType.indexOf('/')
+ if (slashIdx >= 0 && unitedMimeType.regionMatches(0, itemMimeType, 0, slashIdx + 1)) {
+ return buildString {
+ append(unitedMimeType.substring(0, slashIdx + 1))
+ append('*')
+ }
+ }
+ return "*/*"
+ }
+
+ override fun invoke(selection: List<Item>): Intent = onSelectionChanged(selection)
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
index b0dc3c58..fbdc5853 100644
--- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
@@ -30,6 +30,7 @@ import android.widget.TextView;
import androidx.annotation.Nullable;
+import com.android.intentresolver.ContentTypeHint;
import com.android.intentresolver.R;
import com.android.intentresolver.widget.ActionRow;
@@ -42,26 +43,33 @@ class TextContentPreviewUi extends ContentPreviewUi {
@Nullable
private final CharSequence mPreviewTitle;
@Nullable
+ private final CharSequence mMetadata;
+ @Nullable
private final Uri mPreviewThumbnail;
private final ImageLoader mImageLoader;
private final ChooserContentPreviewUi.ActionFactory mActionFactory;
private final HeadlineGenerator mHeadlineGenerator;
+ private final ContentTypeHint mContentTypeHint;
TextContentPreviewUi(
CoroutineScope scope,
@Nullable CharSequence sharingText,
@Nullable CharSequence previewTitle,
+ @Nullable CharSequence metadata,
@Nullable Uri previewThumbnail,
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ ContentTypeHint contentTypeHint) {
mScope = scope;
mSharingText = sharingText;
mPreviewTitle = previewTitle;
+ mMetadata = metadata;
mPreviewThumbnail = previewThumbnail;
mImageLoader = imageLoader;
mActionFactory = actionFactory;
mHeadlineGenerator = headlineGenerator;
+ mContentTypeHint = contentTypeHint;
}
@Override
@@ -139,7 +147,11 @@ class TextContentPreviewUi extends ContentPreviewUi {
copyButton.setVisibility(View.GONE);
}
- displayHeadline(headlineViewParent, mHeadlineGenerator.getTextHeadline(mSharingText));
+ String headlineText = (mContentTypeHint == ContentTypeHint.ALBUM)
+ ? mHeadlineGenerator.getAlbumHeadline()
+ : mHeadlineGenerator.getTextHeadline(mSharingText);
+ displayHeadline(headlineViewParent, headlineText);
+ displayMetadata(headlineViewParent, mMetadata);
return contentPreviewLayout;
}
diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
index 8ddd5273..0974c79b 100644
--- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
@@ -46,6 +46,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
private final MimeTypeClassifier mTypeClassifier;
private final TransitionElementStatusCallback mTransitionElementStatusCallback;
private final HeadlineGenerator mHeadlineGenerator;
+ @Nullable
+ private final CharSequence mMetadata;
private final Flow<FileInfo> mFileInfoFlow;
private final int mItemCount;
@Nullable
@@ -65,7 +67,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
TransitionElementStatusCallback transitionElementStatusCallback,
Flow<FileInfo> fileInfoFlow,
int itemCount,
- HeadlineGenerator headlineGenerator) {
+ HeadlineGenerator headlineGenerator,
+ @Nullable CharSequence metadata) {
mShowEditAction = isSingleImage;
mIntentMimeType = intentMimeType;
mActionFactory = actionFactory;
@@ -75,6 +78,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
mFileInfoFlow = fileInfoFlow;
mItemCount = itemCount;
mHeadlineGenerator = headlineGenerator;
+ mMetadata = metadata;
JavaFlowHelper.collectToList(scope, fileInfoFlow, this::setFiles);
}
@@ -181,5 +185,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
} else {
displayHeadline(layout, mHeadlineGenerator.getFilesHeadline(count));
}
+ displayMetadata(layout, mMetadata);
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt
new file mode 100644
index 00000000..41638b1f
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.ContentInterface
+import android.database.Cursor
+import android.media.MediaMetadata
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL
+import android.provider.Downloads
+import android.provider.OpenableColumns
+import android.text.TextUtils
+import android.util.Log
+import com.android.intentresolver.measurements.runTracing
+
+internal fun ContentInterface.getTypeSafe(uri: Uri): String? =
+ runTracing("getType") {
+ try {
+ getType(uri)
+ } catch (e: SecurityException) {
+ logProviderPermissionWarning(uri, "mime type")
+ null
+ } catch (t: Throwable) {
+ Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
+ null
+ }
+ }
+
+internal fun ContentInterface.getStreamTypesSafe(uri: Uri): Array<String?> =
+ runTracing("getStreamTypes") {
+ try {
+ getStreamTypes(uri, "*/*") ?: emptyArray()
+ } catch (e: SecurityException) {
+ logProviderPermissionWarning(uri, "stream types")
+ emptyArray<String?>()
+ } catch (t: Throwable) {
+ Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t)
+ emptyArray<String?>()
+ }
+ }
+
+internal fun ContentInterface.querySafe(uri: Uri, columns: Array<String>): Cursor? =
+ runTracing("query") {
+ try {
+ query(uri, columns, null, null)
+ } catch (e: SecurityException) {
+ logProviderPermissionWarning(uri, "metadata")
+ null
+ } catch (t: Throwable) {
+ Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
+ null
+ }
+ }
+
+internal fun Cursor.readSupportsThumbnail(): Boolean =
+ runCatching {
+ val flagColIdx = columnNames.indexOf(DocumentsContract.Document.COLUMN_FLAGS)
+ flagColIdx >= 0 && ((getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0)
+ }
+ .getOrDefault(false)
+
+internal fun Cursor.readPreviewUri(): Uri? =
+ runCatching {
+ columnNames
+ .indexOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI)
+ .takeIf { it >= 0 }
+ ?.let { getString(it)?.let(Uri::parse) }
+ }
+ .getOrNull()
+
+internal fun Cursor.readTitle(): String =
+ runCatching {
+ var nameColIndex = -1
+ var titleColIndex = -1
+ // TODO: double-check why Cursor#getColumnInded didn't work
+ columnNames.forEachIndexed { i, columnName ->
+ when (columnName) {
+ OpenableColumns.DISPLAY_NAME -> nameColIndex = i
+ Downloads.Impl.COLUMN_TITLE -> titleColIndex = i
+ }
+ }
+
+ var title = ""
+ if (nameColIndex >= 0) {
+ title = getString(nameColIndex) ?: ""
+ }
+ if (TextUtils.isEmpty(title) && titleColIndex >= 0) {
+ title = getString(titleColIndex) ?: ""
+ }
+ title
+ }
+ .getOrDefault("")
+
+private fun logProviderPermissionWarning(uri: Uri, dataName: String) {
+ // The ContentResolver already logs the exception. Log something more informative.
+ Log.w(
+ ContentPreviewUi.TAG,
+ "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" +
+ " ensure that the sharesheet is given permission."
+ )
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt
new file mode 100644
index 00000000..45515e25
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.intentresolver.contentpreview
+
+import android.content.ContentInterface
+import android.media.MediaMetadata
+import android.net.Uri
+import android.provider.DocumentsContract
+
+class UriMetadataReader(
+ private val contentResolver: ContentInterface,
+ private val typeClassifier: MimeTypeClassifier,
+) : (Uri) -> FileInfo {
+ fun getMetadata(uri: Uri): FileInfo {
+ val builder = FileInfo.Builder(uri)
+ val mimeType = contentResolver.getTypeSafe(uri)
+ builder.withMimeType(mimeType)
+ if (
+ typeClassifier.isImageType(mimeType) ||
+ contentResolver.supportsImageType(uri) ||
+ contentResolver.supportsThumbnail(uri)
+ ) {
+ builder.withPreviewUri(uri)
+ return builder.build()
+ }
+ val previewUri = contentResolver.readPreviewUri(uri)
+ if (previewUri != null) {
+ builder.withPreviewUri(previewUri)
+ }
+ return builder.build()
+ }
+
+ override fun invoke(uri: Uri): FileInfo = getMetadata(uri)
+
+ private fun ContentInterface.supportsImageType(uri: Uri): Boolean =
+ getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null
+
+ private fun ContentInterface.supportsThumbnail(uri: Uri): Boolean =
+ querySafe(uri, arrayOf(DocumentsContract.Document.COLUMN_FLAGS))?.use { cursor ->
+ cursor.moveToFirst() && cursor.readSupportsThumbnail()
+ }
+ ?: false
+
+ private fun ContentInterface.readPreviewUri(uri: Uri): Uri? =
+ querySafe(uri, arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI))?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.readPreviewUri()
+ } else {
+ null
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt
new file mode 100644
index 00000000..87fb7618
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.intentresolver.contentpreview.shareousel.ui.composable
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.res.Resources
+import androidx.compose.foundation.Image
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import com.android.intentresolver.icon.AdaptiveIcon
+import com.android.intentresolver.icon.BitmapIcon
+import com.android.intentresolver.icon.ComposeIcon
+import com.android.intentresolver.icon.ResourceIcon
+
+@Composable
+fun Image(icon: ComposeIcon) {
+ when (icon) {
+ is AdaptiveIcon -> Image(icon.wrapped)
+ is BitmapIcon -> Image(icon.bitmap.asImageBitmap(), contentDescription = null)
+ is ResourceIcon -> {
+ val localContext = LocalContext.current
+ val wrappedContext: Context =
+ object : ContextWrapper(localContext) {
+ override fun getResources(): Resources = icon.res
+ }
+ CompositionLocalProvider(LocalContext provides wrappedContext) {
+ Image(painterResource(icon.resId), contentDescription = null)
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt
new file mode 100644
index 00000000..dc96e3c1
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.intentresolver.contentpreview.shareousel.ui.composable
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.android.intentresolver.R
+
+@Composable
+fun ShareouselCard(
+ image: @Composable () -> Unit,
+ selected: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier) {
+ image()
+ val topButtonPadding = 12.dp
+ Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) {
+ SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart))
+ AnimationIcon(modifier = Modifier.align(Alignment.TopEnd))
+ }
+ }
+}
+
+@Composable
+private fun AnimationIcon(modifier: Modifier = Modifier) {
+ Icon(
+ painterResource(id = R.drawable.ic_play_circle_filled_24px),
+ "animating",
+ tint = Color.White,
+ modifier = Modifier.size(20.dp).then(modifier)
+ )
+}
+
+@Composable
+private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) {
+ if (selected) {
+ val bgColor = MaterialTheme.colorScheme.primary
+ Icon(
+ painter = painterResource(id = R.drawable.checkbox),
+ tint = Color.White,
+ contentDescription = "selected",
+ modifier =
+ Modifier.shadow(
+ elevation = 50.dp,
+ spotColor = Color(0x40000000),
+ ambientColor = Color(0x40000000)
+ )
+ .size(20.dp)
+ .drawBehind {
+ drawCircle(color = bgColor, radius = (this.size.width / 2f) - 1f)
+ }
+ .then(modifier)
+ )
+ } else {
+ Box(
+ modifier =
+ Modifier.shadow(
+ elevation = 50.dp,
+ spotColor = Color(0x40000000),
+ ambientColor = Color(0x40000000),
+ )
+ .border(width = 2.dp, color = Color(0xFFFFFFFF), shape = CircleShape)
+ .clip(CircleShape)
+ .size(20.dp)
+ .background(color = Color(0x7DC4C4C4))
+ .then(modifier)
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt
new file mode 100644
index 00000000..5cf35297
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.intentresolver.contentpreview.shareousel.ui.composable
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.android.intentresolver.R
+import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselImageViewModel
+import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel
+
+@Composable
+fun Shareousel(viewModel: ShareouselViewModel) {
+ val centerIdx = viewModel.centerIndex.value
+ val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx)
+ val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle()
+ Column {
+ // TODO: item needs to be centered, check out ScalingLazyColumn impl or see if
+ // HorizontalPager works for our use-case
+ LazyRow(
+ state = carouselState,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier =
+ Modifier.fillMaxWidth()
+ .height(dimensionResource(R.dimen.chooser_preview_image_height_tall))
+ ) {
+ items(previewKeys, key = viewModel.previewRowKey) { key ->
+ ShareouselCard(viewModel.previewForKey(key))
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+
+ val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList())
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(actions) { actionViewModel ->
+ ShareouselAction(
+ label = actionViewModel.label,
+ onClick = actionViewModel.onClick,
+ ) {
+ actionViewModel.icon?.let { Image(it) }
+ }
+ }
+ }
+ }
+}
+
+private const val MIN_ASPECT_RATIO = 0.4f
+private const val MAX_ASPECT_RATIO = 2.5f
+
+@Composable
+private fun ShareouselCard(viewModel: ShareouselImageViewModel) {
+ val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null)
+ val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false)
+ val contentDescription by
+ viewModel.contentDescription.collectAsStateWithLifecycle(initialValue = null)
+ val borderColor = MaterialTheme.colorScheme.primary
+
+ ShareouselCard(
+ image = {
+ bitmap?.let { bitmap ->
+ val aspectRatio =
+ (bitmap.width.toFloat() / bitmap.height.toFloat())
+ // TODO: max ratio is actually equal to the viewport ratio
+ .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.aspectRatio(aspectRatio),
+ )
+ }
+ ?: run {
+ // TODO: look at ScrollableImagePreviewView.setLoading()
+ Box(modifier = Modifier.aspectRatio(2f / 5f))
+ }
+ },
+ selected = selected,
+ modifier =
+ Modifier.thenIf(selected) {
+ Modifier.border(
+ width = 4.dp,
+ color = borderColor,
+ shape = RoundedCornerShape(size = 12.dp)
+ )
+ }
+ .clip(RoundedCornerShape(size = 12.dp))
+ .clickable { viewModel.setSelected(!selected) },
+ )
+}
+
+@Composable
+private fun ShareouselAction(
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ leadingIcon: (@Composable () -> Unit)? = null,
+) {
+ AssistChip(
+ onClick = onClick,
+ label = { Text(label) },
+ leadingIcon = leadingIcon,
+ modifier = modifier
+ )
+}
+
+inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier =
+ if (condition) this.then(factory()) else this
diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt
new file mode 100644
index 00000000..18ee2539
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.intentresolver.contentpreview.shareousel.ui.viewmodel
+
+import android.graphics.Bitmap
+import androidx.core.graphics.drawable.toBitmap
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
+import com.android.intentresolver.contentpreview.ImageLoader
+import com.android.intentresolver.contentpreview.MutableActionFactory
+import com.android.intentresolver.contentpreview.PayloadToggleInteractor
+import com.android.intentresolver.icon.BitmapIcon
+import com.android.intentresolver.icon.ComposeIcon
+import com.android.intentresolver.widget.ActionRow.Action
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+
+data class ShareouselViewModel(
+ val headline: Flow<String>,
+ val previewKeys: StateFlow<List<Any>>,
+ val actions: Flow<List<ActionChipViewModel>>,
+ val centerIndex: StateFlow<Int>,
+ val previewForKey: (key: Any) -> ShareouselImageViewModel,
+ val previewRowKey: (Any) -> Any
+)
+
+data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit)
+
+data class ShareouselImageViewModel(
+ val bitmap: Flow<Bitmap?>,
+ val contentDescription: Flow<String>,
+ val isSelected: Flow<Boolean>,
+ val setSelected: (Boolean) -> Unit,
+)
+
+suspend fun PayloadToggleInteractor.toShareouselViewModel(
+ imageLoader: ImageLoader,
+ actionFactory: ActionFactory,
+ scope: CoroutineScope,
+): ShareouselViewModel {
+ return ShareouselViewModel(
+ headline = MutableStateFlow("Shareousel"),
+ previewKeys = previewKeys.stateIn(scope),
+ actions =
+ if (actionFactory is MutableActionFactory) {
+ actionFactory.customActionsFlow.map { actions ->
+ actions.map { it.toActionChipViewModel() }
+ }
+ } else {
+ flow {
+ emit(actionFactory.createCustomActions().map { it.toActionChipViewModel() })
+ }
+ },
+ centerIndex = targetPosition.stateIn(scope),
+ previewForKey = { key ->
+ val previewInteractor = previewInteractor(key)
+ ShareouselImageViewModel(
+ bitmap = previewInteractor.previewUri.map { uri -> uri?.let { imageLoader(uri) } },
+ contentDescription = MutableStateFlow(""),
+ isSelected = previewInteractor.selected,
+ setSelected = { isSelected -> previewInteractor.setSelected(isSelected) },
+ )
+ },
+ previewRowKey = { getKey(it) },
+ )
+}
+
+private fun Action.toActionChipViewModel() =
+ ActionChipViewModel(
+ label?.toString() ?: "",
+ icon?.let { BitmapIcon(it.toBitmap()) },
+ onClick = { onClicked.run() }
+ )
diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java
index 2653c560..5f10cf32 100644
--- a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java
+++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java
@@ -29,9 +29,9 @@ import android.stats.devicepolicy.nano.DevicePolicyEnums;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import com.android.intentresolver.R;
import com.android.intentresolver.ResolvedComponentInfo;
import com.android.intentresolver.ResolverListAdapter;
-import com.android.internal.R;
import java.util.List;
diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
index 51d4e677..036b686b 100644
--- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
+++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
@@ -85,19 +85,11 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
* long-pressed.
*/
void onTargetLongPressed(int itemIndex);
-
- /**
- * Notify the client that the provided {@code View} should be configured as the new
- * "profile view" button. Callers should attach their own click listeners to implement
- * behaviors on this view.
- */
- void updateProfileViewButton(View newButtonFromProfileRow);
}
private static final int VIEW_TYPE_DIRECT_SHARE = 0;
private static final int VIEW_TYPE_NORMAL = 1;
private static final int VIEW_TYPE_CONTENT_PREVIEW = 2;
- private static final int VIEW_TYPE_PROFILE = 3;
private static final int VIEW_TYPE_AZ_LABEL = 4;
private static final int VIEW_TYPE_CALLER_AND_RANK = 5;
private static final int VIEW_TYPE_FOOTER = 6;
@@ -170,7 +162,14 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
}
public void setFooterHeight(int height) {
- mFooterHeight = height;
+ if (mFooterHeight != height) {
+ mFooterHeight = height;
+ if (mFeatureFlags.fixTargetListFooter()) {
+ // we always have at least one view, the footer, see getItemCount() and
+ // getFooterRowCount()
+ notifyItemChanged(getItemCount() - 1);
+ }
+ }
}
/**
@@ -201,7 +200,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
public int getRowCount() {
return (int) (
getSystemRowCount()
- + getProfileRowCount()
+ getServiceTargetRowCount()
+ getCallerAndRankedTargetRowCount()
+ getAzLabelRowCount()
@@ -234,13 +232,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
return 1;
}
- public int getProfileRowCount() {
- if (mChooserActivityDelegate.shouldShowTabs()) {
- return 0;
- }
- return mChooserListAdapter.getOtherProfile() == null ? 0 : 1;
- }
-
public int getFooterRowCount() {
return 1;
}
@@ -272,7 +263,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
}
return getSystemRowCount()
- + getProfileRowCount()
+ getServiceTargetRowCount()
+ getCallerAndRankedTargetRowCount();
}
@@ -280,7 +270,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
@Override
public int getItemCount() {
return getSystemRowCount()
- + getProfileRowCount()
+ getServiceTargetRowCount()
+ getCallerAndRankedTargetRowCount()
+ getAzLabelRowCount()
@@ -298,12 +287,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
viewType,
null,
null);
- case VIEW_TYPE_PROFILE:
- return new ItemViewHolder(
- createProfileView(parent),
- viewType,
- null,
- null);
case VIEW_TYPE_AZ_LABEL:
return new ItemViewHolder(
createAzLabelView(parent),
@@ -379,9 +362,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
int countSum = (count = getSystemRowCount());
if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW;
- countSum += (count = getProfileRowCount());
- if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE;
-
countSum += (count = getServiceTargetRowCount());
if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE;
@@ -400,12 +380,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
return mChooserListAdapter.getPositionTargetType(getListPosition(position));
}
- private View createProfileView(ViewGroup parent) {
- View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false);
- mChooserActivityDelegate.updateProfileViewButton(profileRow);
- return profileRow;
- }
-
private View createAzLabelView(ViewGroup parent) {
return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false);
}
@@ -583,7 +557,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
}
int getListPosition(int position) {
- position -= getSystemRowCount() + getProfileRowCount();
+ position -= getSystemRowCount();
final int serviceCount = mChooserListAdapter.getServiceTargetCount();
final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow);
diff --git a/java/src/com/android/intentresolver/icon/ComposeIcon.kt b/java/src/com/android/intentresolver/icon/ComposeIcon.kt
new file mode 100644
index 00000000..dbea1e55
--- /dev/null
+++ b/java/src/com/android/intentresolver/icon/ComposeIcon.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2024 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.icon
+
+import android.content.ContentResolver
+import android.content.pm.PackageManager
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.drawable.Icon
+import java.io.File
+import java.io.FileInputStream
+
+sealed interface ComposeIcon
+
+data class BitmapIcon(val bitmap: Bitmap) : ComposeIcon
+
+data class ResourceIcon(val resId: Int, val res: Resources) : ComposeIcon
+
+@JvmInline value class AdaptiveIcon(val wrapped: ComposeIcon) : ComposeIcon
+
+fun Icon.toComposeIcon(pm: PackageManager, resolver: ContentResolver): ComposeIcon? {
+ return when (type) {
+ Icon.TYPE_BITMAP -> BitmapIcon(bitmap)
+ Icon.TYPE_RESOURCE -> pm.resourcesForPackage(resPackage)?.let { ResourceIcon(resId, it) }
+ Icon.TYPE_DATA ->
+ BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength))
+ Icon.TYPE_URI -> uriIcon(resolver)
+ Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap))
+ Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) }
+ else -> error("unexpected icon type: $type")
+ }
+}
+
+fun Icon.toComposeIcon(resources: Resources?, resolver: ContentResolver): ComposeIcon? {
+ return when (type) {
+ Icon.TYPE_BITMAP -> BitmapIcon(bitmap)
+ Icon.TYPE_RESOURCE -> resources?.let { ResourceIcon(resId, resources) }
+ Icon.TYPE_DATA ->
+ BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength))
+ Icon.TYPE_URI -> uriIcon(resolver)
+ Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap))
+ Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) }
+ else -> error("unexpected icon type: $type")
+ }
+}
+
+// TODO: this is probably constant and doesn't need to be re-queried for each icon
+fun PackageManager.resourcesForPackage(pkgName: String): Resources? {
+ return if (pkgName == "android") {
+ Resources.getSystem()
+ } else {
+ runCatching {
+ this@resourcesForPackage.getApplicationInfo(
+ pkgName,
+ PackageManager.MATCH_UNINSTALLED_PACKAGES or
+ PackageManager.GET_SHARED_LIBRARY_FILES
+ )
+ }
+ .getOrNull()
+ ?.let { ai -> getResourcesForApplication(ai) }
+ }
+}
+
+private fun Icon.uriIcon(resolver: ContentResolver): BitmapIcon? {
+ return runCatching {
+ when (uri.scheme) {
+ ContentResolver.SCHEME_CONTENT,
+ ContentResolver.SCHEME_FILE -> resolver.openInputStream(uri)
+ else -> FileInputStream(File(uriString))
+ }
+ }
+ .getOrNull()
+ ?.let { inStream -> BitmapIcon(BitmapFactory.decodeStream(inStream)) }
+}
diff --git a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt
index 05cf2104..0f9a18c1 100644
--- a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt
+++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt
@@ -1,15 +1,25 @@
package com.android.intentresolver.inject
-import com.android.intentresolver.FeatureFlags
-import com.android.intentresolver.FeatureFlagsImpl
+import android.service.chooser.FeatureFlagsImpl as ChooserServiceFlagsImpl
+import com.android.intentresolver.FeatureFlagsImpl as IntentResolverFlagsImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+typealias IntentResolverFlags = com.android.intentresolver.FeatureFlags
+
+typealias FakeIntentResolverFlags = com.android.intentresolver.FakeFeatureFlagsImpl
+
+typealias ChooserServiceFlags = android.service.chooser.FeatureFlags
+
+typealias FakeChooserServiceFlags = android.service.chooser.FakeFeatureFlagsImpl
+
@Module
@InstallIn(SingletonComponent::class)
object FeatureFlagsModule {
- @Provides fun featureFlags(): FeatureFlags = FeatureFlagsImpl()
+ @Provides fun intentResolverFlags(): IntentResolverFlags = IntentResolverFlagsImpl()
+
+ @Provides fun chooserServiceFlags(): ChooserServiceFlags = ChooserServiceFlagsImpl()
}
diff --git a/java/src/com/android/intentresolver/inject/FrameworkModule.kt b/java/src/com/android/intentresolver/inject/FrameworkModule.kt
deleted file mode 100644
index 2f6cc6a0..00000000
--- a/java/src/com/android/intentresolver/inject/FrameworkModule.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * 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/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt
new file mode 100644
index 00000000..32894d43
--- /dev/null
+++ b/java/src/com/android/intentresolver/inject/SystemServices.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2024 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 androidx.core.content.getSystemService
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+
+inline fun <reified T> Context.requireSystemService(): T {
+ return checkNotNull(getSystemService())
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class ActivityManagerModule {
+ @Provides
+ fun activityManager(@ApplicationContext ctx: Context): ActivityManager =
+ ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class ClipboardManagerModule {
+ @Provides
+ fun clipboardManager(@ApplicationContext ctx: Context): ClipboardManager =
+ ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class ContentResolverModule {
+ @Provides
+ fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver)
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class DevicePolicyManagerModule {
+ @Provides
+ fun devicePolicyManager(@ApplicationContext ctx: Context): DevicePolicyManager =
+ ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class LauncherAppsModule {
+ @Provides
+ fun launcherApps(@ApplicationContext ctx: Context): LauncherApps = ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class PackageManagerModule {
+ @Provides
+ fun packageManager(@ApplicationContext ctx: Context) = requireNotNull(ctx.packageManager)
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class ShortcutManagerModule {
+ @Provides
+ fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager =
+ ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class UserManagerModule {
+ @Provides
+ fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService()
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class WindowManagerModule {
+ @Provides
+ fun windowManager(@ApplicationContext ctx: Context): WindowManager = ctx.requireSystemService()
+}
diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
index 0651d26c..c6de3260 100644
--- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
@@ -107,16 +107,20 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp
.setClassName(target.name.getClassName())
.build());
}
- mAppPredictor.sortTargets(
- appTargets,
- Executors.newSingleThreadExecutor(),
- new ScopedAppTargetListCallback(
- mContext,
- sortedAppTargets -> {
- onAppTargetsSorted(targets, sortedAppTargets);
- return kotlin.Unit.INSTANCE;
- }).toConsumer()
- );
+ try {
+ mAppPredictor.sortTargets(
+ appTargets,
+ Executors.newSingleThreadExecutor(),
+ new ScopedAppTargetListCallback(
+ mContext,
+ sortedAppTargets -> {
+ onAppTargetsSorted(targets, sortedAppTargets);
+ return kotlin.Unit.INSTANCE;
+ }).toConsumer()
+ );
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Couldn't sort targets with AppPredictionService", e);
+ }
}
private void onAppTargetsSorted(
@@ -292,8 +296,12 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp
new AppTarget.Builder(targetId, targetComponent.getPackageName(), mUser)
.setClassName(targetComponent.getClassName())
.build();
- mAppPredictor.notifyAppTargetEvent(
- new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build());
+ try {
+ mAppPredictor.notifyAppTargetEvent(
+ new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build());
+ } catch (IllegalStateException e) {
+ Log.w(TAG, "Couldn't send feedback to AppPredictionService", e);
+ }
}
}
}
diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
index 82f40b91..e544e064 100644
--- a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
+++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
@@ -41,16 +41,14 @@ private const val SHARED_TEXT_KEY = "shared_text"
class AppPredictorFactory(
private val context: Context,
private val sharedText: String?,
- private val targetIntentFilter: IntentFilter?
+ private val targetIntentFilter: IntentFilter?,
+ private val appPredictionAvailable: Boolean,
) {
- private val mIsComponentAvailable =
- context.packageManager.appPredictionServicePackageName != null
-
/**
* Creates an AppPredictor instance for a profile or `null` if app predictor is not available.
*/
fun create(userHandle: UserHandle): AppPredictor? {
- if (!mIsComponentAvailable) return null
+ if (!appPredictionAvailable) return null
val contextAsUser = context.createContextAsUser(userHandle, 0 /* flags */)
val extras = Bundle().apply {
putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter)
diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
index a8b59fb0..08230d90 100644
--- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
+++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
@@ -186,6 +186,11 @@ constructor(
// Default to just querying ShortcutManager if AppPredictor not present.
if (targetIntentFilter == null) {
Log.d(TAG, "skip querying ShortcutManager for $userHandle")
+ sendShareShortcutInfoList(
+ emptyList(),
+ isFromAppPredictor = false,
+ appPredictorTargets = null
+ )
return
}
Log.d(TAG, "query ShortcutManager for user $userHandle")
diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt
index c81bed09..62ace0da 100644
--- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt
+++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt
@@ -1,18 +1,27 @@
+/*
+ * Copyright (C) 2024 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.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
@@ -20,38 +29,7 @@ import com.android.intentresolver.icons.TargetDataLoader
* 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()
-}
+interface ActivityLogic : CommonActivityLogic
/**
* Logic that is common to all IntentResolver activities. Anything that is the same across
@@ -60,21 +38,15 @@ interface ActivityLogic : CommonActivityLogic {
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?
}
/**
@@ -84,73 +56,24 @@ interface CommonActivityLogic {
*/
class CommonActivityLogicImpl(
override val tag: String,
- activityProvider: () -> ComponentActivity,
+ override val activity: 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
- }
- }
- }
+ private val userManager: UserManager = activity.getSystemService()!!
- override val userManager: UserManager by lazy { activity.getSystemService()!! }
-
- override val devicePolicyManager: DevicePolicyManager by lazy { activity.getSystemService()!! }
-
- override val annotatedUserHandles: AnnotatedUserHandles? by lazy {
+ override val annotatedUserHandles: AnnotatedUserHandles? =
try {
AnnotatedUserHandles.forShareActivity(activity)
} catch (e: SecurityException) {
Log.e(tag, "Request from UID without necessary permissions", e)
null
}
- }
- override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy {
+ override val workProfileAvailabilityManager =
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
index db840387..9077a18d 100644
--- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java
+++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java
@@ -40,6 +40,8 @@ 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.v2.ui.ShareResultSender;
+import com.android.intentresolver.v2.ui.model.ShareAction;
import com.android.intentresolver.widget.ActionRow;
import com.android.internal.annotations.VisibleForTesting;
@@ -97,12 +99,12 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
private final Context mContext;
- @Nullable
- private final Runnable mCopyButtonRunnable;
- private final Runnable mEditButtonRunnable;
+ @Nullable private Runnable mCopyButtonRunnable;
+ private Runnable mEditButtonRunnable;
private final ImmutableList<ChooserAction> mCustomActions;
- private final @Nullable ChooserAction mModifyShareAction;
+ @Nullable private final ChooserAction mModifyShareAction;
private final Consumer<Boolean> mExcludeSharedTextAction;
+ @Nullable private final ShareResultSender mShareResultSender;
private final Consumer</* @Nullable */ Integer> mFinishCallback;
private final EventLog mLog;
@@ -122,17 +124,19 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
Intent targetIntent,
String referrerPackageName,
List<ChooserAction> chooserActions,
- ChooserAction modifyShareAction,
+ @Nullable ChooserAction modifyShareAction,
Optional<ComponentName> imageEditor,
EventLog log,
Consumer<Boolean> onUpdateSharedTextIsExcluded,
Callable</* @Nullable */ View> firstVisibleImageQuery,
ActionActivityStarter activityStarter,
- Consumer</* @Nullable */ Integer> finishCallback) {
+ @Nullable ShareResultSender shareResultSender,
+ Consumer</* @Nullable */ Integer> finishCallback,
+ ClipboardManager clipboardManager) {
this(
context,
makeCopyButtonRunnable(
- context,
+ clipboardManager,
targetIntent,
referrerPackageName,
finishCallback,
@@ -149,7 +153,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
modifyShareAction,
onUpdateSharedTextIsExcluded,
log,
+ shareResultSender,
finishCallback);
+
}
@VisibleForTesting
@@ -161,6 +167,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
@Nullable ChooserAction modifyShareAction,
Consumer<Boolean> onUpdateSharedTextIsExcluded,
EventLog log,
+ @Nullable ShareResultSender shareResultSender,
Consumer</* @Nullable */ Integer> finishCallback) {
mContext = context;
mCopyButtonRunnable = copyButtonRunnable;
@@ -169,7 +176,21 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
mModifyShareAction = modifyShareAction;
mExcludeSharedTextAction = onUpdateSharedTextIsExcluded;
mLog = log;
+ mShareResultSender = shareResultSender;
mFinishCallback = finishCallback;
+
+ if (mShareResultSender != null) {
+ mEditButtonRunnable = () -> {
+ mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT);
+ editButtonRunnable.run();
+ };
+ if (mCopyButtonRunnable != null) {
+ mCopyButtonRunnable = () -> {
+ mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY);
+ copyButtonRunnable.run();
+ };
+ }
+ }
}
@Override
@@ -191,13 +212,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
for (int i = 0; i < mCustomActions.size(); i++) {
final int position = i;
ActionRow.Action actionRow = createCustomAction(
- mContext,
- mCustomActions.get(i),
- mFinishCallback,
- () -> {
- mLog.logCustomActionSelected(position);
- }
- );
+ mCustomActions.get(i), () -> logCustomAction(position));
if (actionRow != null) {
actions.add(actionRow);
}
@@ -211,13 +226,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
@Override
@Nullable
public ActionRow.Action getModifyShareAction() {
- return createCustomAction(
- mContext,
- mModifyShareAction,
- mFinishCallback,
- () -> {
- mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE);
- });
+ return createCustomAction(mModifyShareAction, this::logModifyShareAction);
}
/**
@@ -236,7 +245,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
@Nullable
private static Runnable makeCopyButtonRunnable(
- Context context,
+ ClipboardManager clipboardManager,
Intent targetIntent,
String referrerPackageName,
Consumer<Integer> finishCallback,
@@ -252,8 +261,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
return null;
}
return () -> {
- ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(
- Context.CLIPBOARD_SERVICE);
clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName);
log.logActionSelected(EventLog.SELECTION_TYPE_COPY);
@@ -353,15 +360,11 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
}
@Nullable
- private static ActionRow.Action createCustomAction(
- Context context,
- ChooserAction action,
- Consumer<Integer> finishCallback,
- Runnable loggingRunnable) {
- if (action == null || action.getAction() == null) {
+ ActionRow.Action createCustomAction(@Nullable ChooserAction action, Runnable loggingRunnable) {
+ if (action == null) {
return null;
}
- Drawable icon = action.getIcon().loadDrawable(context);
+ Drawable icon = action.getIcon().loadDrawable(mContext);
if (icon == null && TextUtils.isEmpty(action.getLabel())) {
return null;
}
@@ -378,7 +381,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
null,
null,
ActivityOptions.makeCustomAnimation(
- context,
+ mContext,
R.anim.slide_in_right,
R.anim.slide_out_left)
.toBundle());
@@ -388,8 +391,19 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
if (loggingRunnable != null) {
loggingRunnable.run();
}
- finishCallback.accept(Activity.RESULT_OK);
+ if (mShareResultSender != null) {
+ mShareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED);
+ }
+ mFinishCallback.accept(Activity.RESULT_OK);
}
);
}
+
+ void logCustomAction(int position) {
+ mLog.logCustomActionSelected(position);
+ }
+
+ private void logModifyShareAction() {
+ mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE);
+ }
}
diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java
index 70812642..7b5ff541 100644
--- a/java/src/com/android/intentresolver/v2/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2008 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -16,27 +16,39 @@
package com.android.intentresolver.v2;
+import static android.app.VoiceInteractor.PickOptionRequest.Option;
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.content.Intent.FLAG_ACTIVITY_NEW_TASK;
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 androidx.lifecycle.LifecycleKt.getCoroutineScope;
+import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION;
+import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs;
+import static com.android.intentresolver.v2.ui.model.ActivityModel.ACTIVITY_MODEL_KEY;
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED;
import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET;
+import static java.util.Collections.emptyList;
import static java.util.Objects.requireNonNull;
+import static java.util.Objects.requireNonNullElse;
-import android.app.Activity;
import android.app.ActivityManager;
import android.app.ActivityOptions;
+import android.app.ActivityThread;
+import android.app.VoiceInteractor;
+import android.app.admin.DevicePolicyEventLogger;
import android.app.prediction.AppPredictor;
import android.app.prediction.AppTarget;
import android.app.prediction.AppTargetEvent;
import android.app.prediction.AppTargetId;
+import android.content.ClipboardManager;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
@@ -45,32 +57,48 @@ import android.content.IntentFilter;
import android.content.IntentSender;
import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.ShortcutInfo;
+import android.content.pm.UserInfo;
import android.content.res.Configuration;
import android.database.Cursor;
import android.graphics.Insets;
import android.net.Uri;
+import android.os.Build;
import android.os.Bundle;
+import android.os.StrictMode;
import android.os.SystemClock;
+import android.os.Trace;
import android.os.UserHandle;
import android.os.UserManager;
import android.service.chooser.ChooserTarget;
+import android.stats.devicepolicy.DevicePolicyEnums;
+import android.text.TextUtils;
import android.util.Log;
import android.util.Slog;
-import android.util.SparseArray;
+import android.view.Gravity;
+import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewTreeObserver;
+import android.view.Window;
import android.view.WindowInsets;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TabHost;
import android.widget.TextView;
+import android.widget.Toast;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
+import androidx.lifecycle.viewmodel.CreationExtras;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager;
@@ -79,23 +107,28 @@ 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.PackagesChangedListener;
import com.android.intentresolver.R;
import com.android.intentresolver.ResolverListAdapter;
import com.android.intentresolver.ResolverListController;
import com.android.intentresolver.ResolverViewPager;
+import com.android.intentresolver.StartsSelectedItem;
+import com.android.intentresolver.WorkProfileAvailabilityManager;
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.PayloadToggleInteractor;
import com.android.intentresolver.contentpreview.PreviewViewModel;
+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.grid.ChooserGridAdapter;
@@ -107,28 +140,50 @@ 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.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.platform.AppPredictionAvailable;
import com.android.intentresolver.v2.platform.ImageEditor;
import com.android.intentresolver.v2.platform.NearbyShare;
+import com.android.intentresolver.v2.profiles.ChooserMultiProfilePagerAdapter;
+import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter;
+import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType;
+import com.android.intentresolver.v2.profiles.OnProfileSelectedListener;
+import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener;
+import com.android.intentresolver.v2.profiles.TabConfig;
+import com.android.intentresolver.v2.ui.ActionTitle;
+import com.android.intentresolver.v2.ui.ShareResultSender;
+import com.android.intentresolver.v2.ui.ShareResultSenderFactory;
+import com.android.intentresolver.v2.ui.model.ActivityModel;
+import com.android.intentresolver.v2.ui.model.ChooserRequest;
+import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel;
import com.android.intentresolver.widget.ImagePreviewView;
+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.MetricsEvent;
+import com.android.internal.util.LatencyTracker;
+
+import com.google.common.collect.ImmutableList;
import dagger.hilt.android.AndroidEntryPoint;
+import kotlin.Pair;
import kotlin.Unit;
-import java.text.Collator;
import java.util.ArrayList;
+import java.util.Arrays;
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.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
@@ -142,9 +197,9 @@ import javax.inject.Inject;
*
*/
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
-@AndroidEntryPoint(ResolverActivity.class)
+@AndroidEntryPoint(FragmentActivity.class)
public class ChooserActivity extends Hilt_ChooserActivity implements
- ResolverListAdapter.ResolverListCommunicator {
+ ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem {
private static final String TAG = "ChooserActivity";
/**
@@ -167,6 +222,41 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share";
private static final String SHORTCUT_TARGET = "shortcut_target";
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ // Inherited properties.
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ private static final String TAB_TAG_PERSONAL = "personal";
+ private static final String TAB_TAG_WORK = "work";
+
+ private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key";
+ protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser";
+
+ private int mLayoutId;
+ private UserHandle mHeaderCreatorUser;
+ protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL;
+ protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK;
+ private boolean mRegistered;
+ private PackageMonitor mPersonalPackageMonitor;
+ private PackageMonitor mWorkPackageMonitor;
+ protected View mProfileView;
+
+ protected ActivityLogic mLogic;
+ protected ResolverDrawerLayout mResolverDrawerLayout;
+ protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter;
+ protected final LatencyTracker mLatencyTracker = getLatencyTracker();
+
+ /** See {@link #setRetainInOnStop}. */
+ private boolean mRetainInOnStop;
+ protected Insets mSystemWindowInsets = null;
+ private ResolverActivity.PickTargetOptionRequest mPickOptionRequest;
+
+ @Nullable
+ private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
+
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////
+
+
// 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`.
@@ -184,11 +274,21 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1;
private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2;
+ @Inject public ChooserHelper mChooserHelper;
@Inject public FeatureFlags mFeatureFlags;
+ @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags;
@Inject public EventLog mEventLog;
+ @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable;
@Inject @ImageEditor public Optional<ComponentName> mImageEditor;
@Inject @NearbyShare public Optional<ComponentName> mNearbyShare;
@Inject public TargetDataLoader mTargetDataLoader;
+ @Inject public DevicePolicyResources mDevicePolicyResources;
+ @Inject public PackageManager mPackageManager;
+ @Inject public ClipboardManager mClipboardManager;
+ @Inject public IntentForwarding mIntentForwarding;
+ @Inject public ShareResultSenderFactory mShareResultSenderFactory;
+ @Nullable
+ private ShareResultSender mShareResultSender;
private ChooserRefinementManager mRefinementManager;
@@ -216,14 +316,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
private int mScrollStatus = SCROLL_STATUS_IDLE;
- @VisibleForTesting
- protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter;
private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate =
new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout);
- private View mContentView = null;
+ private final View mContentView = null;
- private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>();
+ private final Map<Integer, ProfileRecord> mProfileRecords = new HashMap<>();
private boolean mExcludeSharedText = false;
/**
@@ -236,35 +334,260 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
private final AtomicLong mIntentReceivedTime = new AtomicLong(-1);
+ protected ActivityModel createActivityModel() {
+ return ActivityModel.createFrom(this);
+ }
+
+ private ChooserViewModel mViewModel;
+ private ActivityModel mActivityModel;
+
+ @VisibleForTesting
+ protected ChooserActivityLogic createActivityLogic() {
+ return new ChooserActivityLogic(
+ TAG,
+ /* activity = */ this,
+ this::onWorkProfileStatusUpdated);
+ }
+
+ @NonNull
+ @Override
+ public CreationExtras getDefaultViewModelCreationExtras() {
+ return addDefaultArgs(
+ super.getDefaultViewModelCreationExtras(),
+ new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel()));
+ }
+
@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);
- }
+ Log.i(TAG, "onCreate");
+ mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class);
+ mActivityModel = mViewModel.getActivityModel();
- private void init() {
- if (getChooserRequest() == null) {
+ int callerUid = mActivityModel.getLaunchedFromUid();
+ if (callerUid < 0 || UserHandle.isIsolated(callerUid)) {
+ Log.e(TAG, "Can't start a resolver from uid " + callerUid);
+ finish();
+ }
+
+ setTheme(R.style.Theme_DeviceDefault_Chooser);
+ Tracer.INSTANCE.markLaunched();
+ if (!mViewModel.init()) {
finish();
return;
}
+
+ // The post-create callback is invoked when this function returns, via Lifecycle.
+ mChooserHelper.setPostCreateCallback(this::init);
+
+ IntentSender chosenComponentSender =
+ mViewModel.getChooserRequest().getChosenComponentSender();
+ if (chosenComponentSender != null) {
+ mShareResultSender = mShareResultSenderFactory
+ .create(mActivityModel.getLaunchedFromUid(), chosenComponentSender);
+ }
+ mLogic = createActivityLogic();
+ }
+
+ @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 onResume() {
+ super.onResume();
+ Log.d(TAG, "onResume: " + getComponentName().flattenToShortString());
+ mFinishWhenStopped = false;
+ mRefinementManager.onActivityResume();
+ }
+
+ @Override
+ protected final 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()
+ && !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();
+ }
+ }
+ mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this);
+
+ if (mRefinementManager != null) {
+ mRefinementManager.onActivityStop(isChangingConfigurations());
+ }
+
+ if (mFinishWhenStopped) {
+ mFinishWhenStopped = false;
+ finish();
+ }
+ }
+
+ @Override
+ protected final void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+ if (viewPager != null) {
+ outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem());
+ }
+ }
+
+ @Override
+ protected final void onRestart() {
+ super.onRestart();
+ if (!mRegistered) {
+ mPersonalPackageMonitor.register(
+ this,
+ getMainLooper(),
+ requireAnnotatedUserHandles().personalProfileUserHandle,
+ false);
+ if (hasWorkProfile()) {
+ if (mWorkPackageMonitor == null) {
+ mWorkPackageMonitor = createPackageMonitor(
+ mChooserMultiProfilePagerAdapter.getWorkListAdapter());
+ }
+ mWorkPackageMonitor.register(
+ this,
+ getMainLooper(),
+ requireAnnotatedUserHandles().workProfileUserHandle,
+ false);
+ }
+ mRegistered = true;
+ }
+ WorkProfileAvailabilityManager workProfileAvailabilityManager =
+ mLogic.getWorkProfileAvailabilityManager();
+ if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) {
+ if (workProfileAvailabilityManager.isQuietModeEnabled()) {
+ workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived();
+ }
+ }
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
if (isFinishing()) {
- // Performing a clean exit:
- // Skip initializing any additional resources.
- return;
+ mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET);
}
- setTheme(mLogic.getThemeResId());
- getEventLog().logSharesheetTriggered();
+ mBackgroundThreadPoolExecutor.shutdownNow();
- mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class);
+ destroyProfileRecords();
+ }
+
+ private void init() {
+ 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);
+
+ ChooserRequest chooserRequest = mViewModel.getChooserRequest();
+ setRetainInOnStop(chooserRequest.shouldRetainInOnStop());
+ createProfileRecords(
+ new AppPredictorFactory(
+ this,
+ Objects.toString(chooserRequest.getSharedText(), null),
+ chooserRequest.getShareTargetFilter(),
+ mAppPredictionAvailable
+ ),
+ chooserRequest.getShareTargetFilter()
+ );
+
+ Intent intent = mViewModel.getChooserRequest().getTargetIntent();
+ List<Intent> initialIntents = mViewModel.getChooserRequest().getInitialIntents();
+
+ mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
+ requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]),
+ /* resolutionList = */ null,
+ false
+ );
+ if (!configureContentView(mTargetDataLoader)) {
+ mPersonalPackageMonitor = createPackageMonitor(
+ mChooserMultiProfilePagerAdapter.getPersonalListAdapter());
+ mPersonalPackageMonitor.register(
+ this,
+ getMainLooper(),
+ requireAnnotatedUserHandles().personalProfileUserHandle,
+ false
+ );
+ if (hasWorkProfile()) {
+ mWorkPackageMonitor = createPackageMonitor(
+ mChooserMultiProfilePagerAdapter.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 = mPackageManager
+ .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;
+ }
+ final Set<String> categories = intent.getCategories();
+ MetricsLogger.action(this,
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
+ ? MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED
+ : MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED,
+ intent.getAction() + ":" + intent.getType() + ":"
+ + (categories != null ? Arrays.toString(categories.toArray())
+ : ""));
+ }
+
+ getEventLog().logSharesheetTriggered();
+ mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class);
mRefinementManager.getRefinementCompletion().observe(this, completion -> {
if (completion.consume()) {
TargetInfo targetInfo = completion.getTargetInfo();
@@ -276,26 +599,56 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
// 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);
+ final ResolveInfo ri = targetInfo.getResolveInfo();
+ final Intent intent1 = targetInfo.getResolvedIntent();
+
+ safelyStartActivity(targetInfo);
+
+ // Rely on the ActivityManager to pop up a dialog regarding app suspension
+ // and return false
+ targetInfo.isSuspended();
}
finish();
}
});
-
BasePreviewViewModel previewViewModel =
new ViewModelProvider(this, createPreviewViewModelFactory())
.get(BasePreviewViewModel.class);
- ChooserRequestParameters chooserRequest = requireChooserRequest();
+ previewViewModel.init(
+ chooserRequest.getTargetIntent(),
+ mActivityModel.getIntent(),
+ chooserRequest.getAdditionalContentUri(),
+ chooserRequest.getFocusedItemPosition(),
+ mChooserServiceFeatureFlags.chooserPayloadToggling());
+ ChooserActionFactory chooserActionFactory = createChooserActionFactory();
+ ChooserContentPreviewUi.ActionFactory actionFactory = chooserActionFactory;
+ if (previewViewModel.getPreviewDataProvider().getPreviewType()
+ == CONTENT_PREVIEW_PAYLOAD_SELECTION
+ && mChooserServiceFeatureFlags.chooserPayloadToggling()) {
+ PayloadToggleInteractor payloadToggleInteractor =
+ previewViewModel.getPayloadToggleInteractor();
+ if (payloadToggleInteractor != null) {
+ ChooserMutableActionFactory mutableActionFactory =
+ new ChooserMutableActionFactory(chooserActionFactory);
+ actionFactory = mutableActionFactory;
+ JavaFlowHelper.collect(
+ getCoroutineScope(getLifecycle()),
+ payloadToggleInteractor.getCustomActions(),
+ mutableActionFactory::updateCustomActions);
+ }
+ }
mChooserContentPreviewUi = new ChooserContentPreviewUi(
getCoroutineScope(getLifecycle()),
- previewViewModel.createOrReuseProvider(chooserRequest.getTargetIntent()),
+ previewViewModel.getPreviewDataProvider(),
chooserRequest.getTargetIntent(),
- previewViewModel.createOrReuseImageLoader(),
- createChooserActionFactory(),
+ previewViewModel.getImageLoader(),
+ actionFactory,
mEnterTransitionAnimationDelegate,
- new HeadlineGeneratorImpl(this));
-
+ new HeadlineGeneratorImpl(this),
+ chooserRequest.getContentTypeHint(),
+ chooserRequest.getMetadataText(),
+ mChooserServiceFeatureFlags.chooserPayloadToggling());
updateStickyContentPreview();
if (shouldShowStickyContentPreview()
|| mChooserMultiProfilePagerAdapter
@@ -303,12 +656,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
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);
@@ -318,64 +669,605 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
getEventLog().logSharesheetExpansionChanged(isCollapsed);
});
}
-
if (DEBUG) {
Log.d(TAG, "System Time Cost is " + systemCost);
}
-
getEventLog().logShareStarted(
- mLogic.getReferrerPackageName(),
+ chooserRequest.getReferrerPackage(),
chooserRequest.getTargetType(),
chooserRequest.getCallerChooserTargets().size(),
- (chooserRequest.getInitialIntents() == null)
- ? 0 : chooserRequest.getInitialIntents().length,
+ chooserRequest.getInitialIntents().size(),
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);
+ private void restore(@Nullable Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ // onRestoreInstanceState
+ //resetButtonBar();
+ ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+ if (viewPager != null) {
+ viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY));
+ }
+ }
- mPinnedSharedPrefs = getPinnedSharedPrefs(this);
- mMaxTargetsPerRow =
- getResources().getInteger(R.integer.config_chooser_max_targets_per_row);
- mShouldDisplayLandscape =
- shouldDisplayLandscape(getResources().getConfiguration().orientation);
+ mChooserMultiProfilePagerAdapter.clearInactiveProfileCache();
+ }
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ // Inherited methods
+ //////////////////////////////////////////////////////////////////////////////////////////////
- ChooserRequestParameters chooserRequest = getChooserRequest();
- if (chooserRequest == null) {
- return Unit.INSTANCE;
+ private boolean isAutolaunching() {
+ return !mRegistered && isFinishing();
+ }
+
+ private boolean maybeAutolaunchIfSingleTarget() {
+ int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();
+ if (count != 1) {
+ return false;
}
- setRetainInOnStop(chooserRequest.shouldRetainInOnStop());
- createProfileRecords(
- new AppPredictorFactory(
- this,
- chooserRequest.getSharedText(),
- chooserRequest.getTargetIntentFilter()
- ),
- chooserRequest.getTargetIntentFilter()
+ if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) {
+ return false;
+ }
+
+ // Only one target, so we're a candidate to auto-launch!
+ final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter()
+ .targetInfoForPosition(0, false);
+ if (shouldAutoLaunchSingleChoice(target)) {
+ safelyStartActivity(target);
+ finish();
+ return true;
+ }
+ return false;
+ }
+
+
+ private boolean isTwoPagePersonalAndWorkConfiguration() {
+ return (mChooserMultiProfilePagerAdapter.getCount() == 2)
+ && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL)
+ && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK);
+ }
+
+ /**
+ * When we have a personal and a work profile, we auto launch in the following scenario:
+ * - There is 1 resolved target on each profile
+ * - That target is the same app on both profiles
+ * - The target app has permission to communicate cross profiles
+ * - The target app has declared it supports cross-profile communication via manifest metadata
+ */
+ private boolean maybeAutolaunchIfCrossProfileSupported() {
+ if (!isTwoPagePersonalAndWorkConfiguration()) {
+ return false;
+ }
+
+ ResolverListAdapter activeListAdapter =
+ (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mChooserMultiProfilePagerAdapter.getPersonalListAdapter()
+ : mChooserMultiProfilePagerAdapter.getWorkListAdapter();
+
+ ResolverListAdapter inactiveListAdapter =
+ (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mChooserMultiProfilePagerAdapter.getWorkListAdapter()
+ : mChooserMultiProfilePagerAdapter.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 (!mIntentForwarding.canAppInteractAcrossProfiles(this, 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;
+ }
+
+ /**
+ * @return {@code true} if a resolved target is autolaunched, otherwise {@code false}
+ */
+ private boolean maybeAutolaunchActivity() {
+ int numberOfProfiles = mChooserMultiProfilePagerAdapter.getItemCount();
+ // 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.
+ if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) {
+ return true;
+ } else if (maybeAutolaunchIfCrossProfileSupported()) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override // ResolverListCommunicator
+ public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing,
+ boolean rebuildCompleted) {
+ if (isAutolaunching()) {
+ return;
+ }
+ if (mChooserMultiProfilePagerAdapter
+ .shouldShowEmptyStateScreen((ChooserListAdapter) listAdapter)) {
+ mChooserMultiProfilePagerAdapter
+ .showEmptyResolverListEmptyState((ChooserListAdapter) listAdapter);
+ } else {
+ mChooserMultiProfilePagerAdapter.showListView((ChooserListAdapter) listAdapter);
+ }
+ // showEmptyResolverListEmptyState can mark the tab as loaded,
+ // which is a precondition for auto launching
+ if (rebuildCompleted && maybeAutolaunchActivity()) {
+ return;
+ }
+ if (doPostProcessing) {
+ maybeCreateHeader(listAdapter);
+ onListRebuilt(listAdapter, rebuildCompleted);
+ }
+ }
+
+ private CharSequence getOrLoadDisplayLabel(TargetInfo info) {
+ if (info.isDisplayResolveInfo()) {
+ mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info);
+ }
+ CharSequence displayLabel = info.getDisplayLabel();
+ return displayLabel == null ? "" : displayLabel;
+ }
+
+ protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) {
+ final ActionTitle title = 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 =
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0;
+ if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) {
+ return getString(defaultTitleRes);
+ } else {
+ return named
+ ? getString(
+ title.namedTitleRes,
+ getOrLoadDisplayLabel(
+ mChooserMultiProfilePagerAdapter
+ .getActiveListAdapter().getFilteredItem()))
+ : getString(title.titleRes);
+ }
+ }
+
+ /**
+ * 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 (!hasWorkProfile()
+ && 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 = mViewModel.getChooserRequest().getTitle() != null
+ ? mViewModel.getChooserRequest().getTitle()
+ : getTitleForAction(mViewModel.getChooserRequest().getTargetIntent(),
+ mViewModel.getChooserRequest().getDefaultTitleResource());
+
+ 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();
+ }
+
+ /** 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);
+ }
+
+ 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 = mIntentForwarding.forwardMessageFor(
+ mViewModel.getChooserRequest().getTargetIntent());
+ if (profileSwitchMessage != null) {
+ Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show();
+ }
+ try {
+ if (cti.startAsCaller(this, options, user.getIdentifier())) {
+ maybeSendShareResult(cti);
+ maybeLogCrossProfileTargetLaunch(cti, user);
+ }
+ } catch (RuntimeException e) {
+ Slog.wtf(TAG,
+ "Unable to launch as uid " + mActivityModel.getLaunchedFromUid()
+ + " package " + mActivityModel.getLaunchedFromPackage() +
+ ", while running in " + ActivityThread.currentProcessName(), e);
+ }
+ }
+
+ private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) {
+ if (!hasWorkProfile() || currentUserHandle.equals(getUser())) {
+ return;
+ }
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED)
+ .setBoolean(
+ currentUserHandle.equals(
+ requireAnnotatedUserHandles().personalProfileUserHandle))
+ .setStrings(getMetricsCategory(),
+ cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target")
+ .write();
+ }
+
+ private boolean hasWorkProfile() {
+ return requireAnnotatedUserHandles().workProfileUserHandle != null;
+ }
+ private LatencyTracker getLatencyTracker() {
+ return LatencyTracker.getInstance(this);
+ }
+
+ /**
+ * 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;
+ }
+
+ // @NonFinalForTesting
+ @VisibleForTesting
+ protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
+ return new CrossProfileIntentsChecker(getContentResolver());
+ }
+
+ 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
);
- return Unit.INSTANCE;
}
- @Nullable
- private ChooserRequestParameters getChooserRequest() {
- return ((ChooserActivityLogic) mLogic).getChooserRequestParameters();
+ private boolean supportsManagedProfiles(ResolveInfo resolveInfo) {
+ try {
+ ApplicationInfo appInfo = mPackageManager.getApplicationInfo(
+ resolveInfo.activityInfo.packageName, 0 /* default flags */);
+ return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP;
+ } catch (PackageManager.NameNotFoundException e) {
+ return false;
+ }
+ }
+
+ 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;
+ }
+
+ /**
+ * 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);
+ }
+
+ protected final boolean isLaunchedAsCloneProfile() {
+ UserHandle launchUser = requireAnnotatedUserHandles().userHandleSharesheetLaunchedAs;
+ UserHandle cloneUser = requireAnnotatedUserHandles().cloneProfileUserHandle;
+ return hasCloneProfile() && launchUser.equals(cloneUser);
+ }
+
+ private boolean hasCloneProfile() {
+ return requireAnnotatedUserHandles().cloneProfileUserHandle != null;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 WindowInsets super_onApplyWindowInsets(View v, WindowInsets insets) {
+ mSystemWindowInsets = insets.getSystemWindowInsets();
+
+ mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
+ mSystemWindowInsets.right, 0);
+
+ // Need extra padding so the list can fully scroll up
+ // To accommodate for window insets
+ applyFooterView(mSystemWindowInsets.bottom);
+
+ return insets.consumeSystemWindowInsets();
+ }
+
+ @Override // ResolverListCommunicator
+ public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
+ if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged(
+ (ChooserListAdapter) listAdapter,
+ mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile())) {
+ // We no longer have any items... just finish the activity.
+ finish();
+ }
+ }
+
+ final Option optionForChooserTarget(TargetInfo target, int index) {
+ return new Option(getOrLoadDisplayLabel(target), index);
}
- private ChooserRequestParameters requireChooserRequest() {
- return requireNonNull(getChooserRequest());
+ @Override // ResolverListCommunicator
+ public final void sendVoiceChoicesIfNeeded() {
+ if (!isVoiceInteraction()) {
+ // Clearly not needed.
+ return;
+ }
+
+ int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount();
+ final Option[] options = new Option[count];
+ for (int i = 0; i < options.length; i++) {
+ TargetInfo target = mChooserMultiProfilePagerAdapter.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 ResolverActivity.PickTargetOptionRequest(
+ new VoiceInteractor.Prompt(getTitle()), options, null);
+ getVoiceInteractor().submitRequest(mPickOptionRequest);
+ }
+
+ /**
+ * Sets up the content view.
+ * @return <code>true</code> if the activity is finishing and creation should halt.
+ */
+ private boolean configureContentView(TargetDataLoader targetDataLoader) {
+ if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) {
+ throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() "
+ + "cannot be null.");
+ }
+ Trace.beginSection("configureContentView");
+ // We partially rebuild the inactive adapter to determine if we should auto launch
+ // isTabLoaded will be true here if the empty state screen is shown instead of the list.
+ boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile());
+
+ mLayoutId = mFeatureFlags.scrollablePreview()
+ ? R.layout.chooser_grid_scrollable_preview
+ : R.layout.chooser_grid;
+
+ setContentView(mLayoutId);
+ mChooserMultiProfilePagerAdapter.setupViewPager(
+ requireViewById(com.android.internal.R.id.profile_pager));
+ boolean result = postRebuildList(rebuildCompleted);
+ Trace.endSection();
+ return result;
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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 (hasWorkProfile()) {
+ textView.setGravity(Gravity.CENTER);
+ }
+ stub.addView(textView);
+ }
+ }
+ private void setupViewVisibilities() {
+ ChooserListAdapter activeListAdapter =
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter();
+ if (!mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) {
+ addUseDifferentAppLabelIfNecessary(activeListAdapter);
+ }
+ }
+ /**
+ * 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 = mChooserMultiProfilePagerAdapter.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 (hasWorkProfile()) {
+ setupProfileTabs();
+ }
+
+ return false;
+ }
+
+ private void setupProfileTabs() {
+ TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost);
+ ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+
+ mChooserMultiProfilePagerAdapter.setupProfileTabs(
+ getLayoutInflater(),
+ tabHost,
+ viewPager,
+ R.layout.resolver_profile_tab_button,
+ com.android.internal.R.id.profile_pager,
+ () -> onProfileTabSelected(viewPager.getCurrentItem()),
+ new OnProfileSelectedListener() {
+ @Override
+ public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {}
+
+ @Override
+ public void onProfilePageStateChanged(int state) {
+ onHorizontalSwipeStateChanged(state);
+ }
+ });
+ mOnSwitchOnWorkSelectedListener = () -> {
+ final View workTab =
+ tabHost.getTabWidget().getChildAt(
+ mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK));
+ workTab.setFocusable(true);
+ workTab.setFocusableInTouchMode(true);
+ workTab.requestFocus();
+ };
}
+ //////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////
+
private AnnotatedUserHandles requireAnnotatedUserHandles() {
return requireNonNull(mLogic.getAnnotatedUserHandles());
}
@@ -412,7 +1304,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
@Nullable
private ProfileRecord getProfileRecord(UserHandle userHandle) {
- return mProfileRecords.get(userHandle.getIdentifier(), null);
+ return mProfileRecords.get(userHandle.getIdentifier());
}
@VisibleForTesting
@@ -435,25 +1327,22 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE);
}
- @Override
protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter(
Intent[] initialIntents,
List<ResolveInfo> rList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
- if (shouldShowTabs()) {
+ boolean filterLastUsed) {
+ if (hasWorkProfile()) {
mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles(
- initialIntents, rList, filterLastUsed, targetDataLoader);
+ initialIntents, rList, filterLastUsed);
} else {
mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile(
- initialIntents, rList, filterLastUsed, targetDataLoader);
+ initialIntents, rList, filterLastUsed);
}
return mChooserMultiProfilePagerAdapter;
}
- @Override
protected EmptyStateProvider createBlockerEmptyStateProvider() {
- final boolean isSendAction = requireChooserRequest().isSendActionTarget();
+ final boolean isSendAction = mViewModel.getChooserRequest().isSendActionTarget();
final EmptyState noWorkToPersonalEmptyState =
new DevicePolicyBlockerEmptyState(
@@ -492,21 +1381,27 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile(
Intent[] initialIntents,
List<ResolveInfo> rList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
+ boolean filterLastUsed) {
ChooserGridAdapter adapter = createChooserGridAdapter(
/* context */ this,
- mLogic.getPayloadIntents(),
+ mViewModel.getChooserRequest().getPayloadIntents(),
initialIntents,
rList,
filterLastUsed,
- /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle,
- targetDataLoader);
+ /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle
+ );
return new ChooserMultiProfilePagerAdapter(
/* context */ this,
- adapter,
+ ImmutableList.of(
+ new TabConfig<>(
+ PROFILE_PERSONAL,
+ mDevicePolicyResources.getPersonalTabLabel(),
+ mDevicePolicyResources.getPersonalTabAccessibilityLabel(),
+ TAB_TAG_PERSONAL,
+ adapter)),
createEmptyStateProvider(/* workProfileUserHandle= */ null),
/* workProfileQuietModeChecker= */ () -> false,
+ /* defaultProfile= */ PROFILE_PERSONAL,
/* workProfileUserHandle= */ null,
requireAnnotatedUserHandles().cloneProfileUserHandle,
mMaxTargetsPerRow,
@@ -516,29 +1411,39 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles(
Intent[] initialIntents,
List<ResolveInfo> rList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
+ boolean filterLastUsed) {
int selectedProfile = findSelectedProfile();
ChooserGridAdapter personalAdapter = createChooserGridAdapter(
/* context */ this,
- mLogic.getPayloadIntents(),
+ mViewModel.getChooserRequest().getPayloadIntents(),
selectedProfile == PROFILE_PERSONAL ? initialIntents : null,
rList,
filterLastUsed,
- /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle,
- targetDataLoader);
+ /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle
+ );
ChooserGridAdapter workAdapter = createChooserGridAdapter(
/* context */ this,
- mLogic.getPayloadIntents(),
+ mViewModel.getChooserRequest().getPayloadIntents(),
selectedProfile == PROFILE_WORK ? initialIntents : null,
rList,
filterLastUsed,
- /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle,
- targetDataLoader);
+ /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle
+ );
return new ChooserMultiProfilePagerAdapter(
/* context */ this,
- personalAdapter,
- workAdapter,
+ ImmutableList.of(
+ new TabConfig<>(
+ PROFILE_PERSONAL,
+ mDevicePolicyResources.getPersonalTabLabel(),
+ mDevicePolicyResources.getPersonalTabAccessibilityLabel(),
+ TAB_TAG_PERSONAL,
+ personalAdapter),
+ new TabConfig<>(
+ PROFILE_WORK,
+ mDevicePolicyResources.getWorkTabLabel(),
+ mDevicePolicyResources.getWorkTabAccessibilityLabel(),
+ TAB_TAG_WORK,
+ workAdapter)),
createEmptyStateProvider(requireAnnotatedUserHandles().workProfileUserHandle),
() -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(),
selectedProfile,
@@ -549,12 +1454,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private int findSelectedProfile() {
- int selectedProfile = getSelectedProfileExtra();
- if (selectedProfile == -1) {
- selectedProfile = getProfileForUser(
- requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch);
- }
- return selectedProfile;
+ return getProfileForUser(requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch);
}
/**
@@ -567,7 +1467,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
.getUserInfo(UserHandle.myUserId()).isManagedProfile();
}
- @Override
+ //@Override
protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) {
return new PackageMonitor() {
@Override
@@ -580,6 +1480,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
/**
* Update UI to reflect changes in data.
*/
+ @Override
public void handlePackagesChanged() {
handlePackagesChanged(/* listAdapter */ null);
}
@@ -597,23 +1498,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
} 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);
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
+
+ if (mSystemWindowInsets != null) {
+ mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top,
+ mSystemWindowInsets.right, 0);
+ }
ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
if (viewPager.isLayoutRtl()) {
- mMultiProfilePagerAdapter.setupViewPager(viewPager);
+ mChooserMultiProfilePagerAdapter.setupViewPager(viewPager);
}
mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation);
@@ -643,7 +1541,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private void updateTabPadding() {
- if (shouldShowTabs()) {
+ if (hasWorkProfile()) {
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
@@ -703,45 +1601,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
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.values().forEach(ProfileRecord::destroy);
mProfileRecords.clear();
}
@Override // ResolverListCommunicator
public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) {
- ChooserRequestParameters chooserRequest = getChooserRequest();
- if (chooserRequest == null) {
- return defIntent;
- }
+ ChooserRequest chooserRequest = mViewModel.getChooserRequest();
Intent result = defIntent;
if (chooserRequest.getReplacementExtras() != null) {
@@ -765,32 +1632,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return result;
}
- @Override
- public void onActivityStarted(TargetInfo cti) {
- ChooserRequestParameters chooserRequest = requireChooserRequest();
- if (chooserRequest.getChosenComponentSender() != null) {
+ private void maybeSendShareResult(TargetInfo cti) {
+ if (mShareResultSender != 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);
- }
+ mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo());
}
}
}
private void addCallerChooserTargets() {
- ChooserRequestParameters chooserRequest = requireChooserRequest();
+ ChooserRequest chooserRequest = mViewModel.getChooserRequest();
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) {
+ if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) {
mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults(
/* origTarget */ null,
new ArrayList<>(chooserRequest.getCallerChooserTargets()),
@@ -801,28 +1656,18 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
}
- @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)) {
+ if (target.isSuspended()) {
return false;
}
- return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true);
+ return mActivityModel.getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE,
+ true);
}
private void showTargetDetails(TargetInfo targetInfo) {
@@ -837,8 +1682,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
// 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;
+ IntentFilter intentFilter;
+ intentFilter = targetInfo.isSelectableTargetInfo()
+ ? mViewModel.getChooserRequest().getShareTargetFilter() : null;
String shortcutTitle = targetInfo.isSelectableTargetInfo()
? targetInfo.getDisplayLabel().toString() : null;
String shortcutIdKey = targetInfo.getDirectShareShortcutId();
@@ -855,22 +1701,25 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
intentFilter);
}
- @Override
- protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) {
+ protected boolean onTargetSelected(TargetInfo target) {
if (mRefinementManager.maybeHandleSelection(
target,
- requireChooserRequest().getRefinementIntentSender(),
+ mViewModel.getChooserRequest().getRefinementIntentSender(),
getApplication(),
getMainThreadHandler())) {
return false;
}
updateModelAndChooserCounts(target);
maybeRemoveSharedText(target);
- return super.onTargetSelected(target, alwaysCheck);
+ safelyStartActivity(target);
+
+ // Rely on the ActivityManager to pop up a dialog regarding app suspension
+ // and return false
+ return !target.isSuspended();
}
@Override
- public void startSelected(int which, boolean always, boolean filtered) {
+ public void startSelected(int which, /* unused */ boolean always, boolean filtered) {
ChooserListAdapter currentListAdapter =
mChooserMultiProfilePagerAdapter.getActiveListAdapter();
TargetInfo targetInfo = currentListAdapter
@@ -893,8 +1742,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return;
}
}
+ if (isFinishing()) {
+ return;
+ }
- super.startSelected(which, always, filtered);
+ TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter()
+ .targetInfoForPosition(which, filtered);
+ if (target != null) {
+ if (onTargetSelected(target)) {
+ MetricsLogger.action(
+ this, MetricsEvent.ACTION_APP_DISAMBIG_TAP);
+ MetricsLogger.action(this,
+ mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
+ ? MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED
+ : MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED);
+ finish();
+ }
+ }
// 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
@@ -914,7 +1778,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
targetInfo.getResolveInfo().activityInfo.processName,
which,
/* directTargetAlsoRanked= */ getRankedPosition(targetInfo),
- requireChooserRequest().getCallerChooserTargets().size(),
+ mViewModel.getChooserRequest().getCallerChooserTargets().size(),
targetInfo.getHashedTargetIdForMetrics(this),
targetInfo.isPinned(),
mIsSuccessfullySelected,
@@ -951,7 +1815,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mIsSuccessfullySelected,
selectionCost
);
- return;
}
}
}
@@ -973,13 +1836,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return -1;
}
- @Override
- protected boolean shouldAddFooterView() {
- // To accommodate for window insets
- return true;
- }
-
- @Override
protected void applyFooterView(int height) {
mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height);
}
@@ -1001,7 +1857,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (info != null) {
sendClickToAppPredictor(info);
final ResolveInfo ri = info.getResolveInfo();
- Intent targetIntent = mLogic.getTargetIntent();
+ Intent targetIntent = mViewModel.getChooserRequest().getTargetIntent();
if (ri != null && ri.activityInfo != null && targetIntent != null) {
ChooserListAdapter currentListAdapter =
mChooserMultiProfilePagerAdapter.getActiveListAdapter();
@@ -1029,7 +1885,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (targetIntent == null) {
return;
}
- Intent originalTargetIntent = new Intent(requireChooserRequest().getTargetIntent());
+ Intent originalTargetIntent = new Intent(mViewModel.getChooserRequest().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) {
@@ -1103,61 +1959,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
? 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,
@@ -1165,9 +1970,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
Intent[] initialIntents,
List<ResolveInfo> rList,
boolean filterLastUsed,
- UserHandle userHandle,
- TargetDataLoader targetDataLoader) {
- ChooserRequestParameters parameters = requireChooserRequest();
+ UserHandle userHandle) {
+ ChooserRequest request = mViewModel.getChooserRequest();
ChooserListAdapter chooserListAdapter = createChooserListAdapter(
context,
payloadIntents,
@@ -1176,17 +1980,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
filterLastUsed,
createListController(userHandle),
userHandle,
- mLogic.getTargetIntent(),
- parameters.getReferrerFillInIntent(),
- mMaxTargetsPerRow,
- targetDataLoader);
+ request.getTargetIntent(),
+ request.getReferrerFillInIntent(),
+ mMaxTargetsPerRow
+ );
return new ChooserGridAdapter(
context,
new ChooserGridAdapter.ChooserActivityDelegate() {
@Override
public boolean shouldShowTabs() {
- return ChooserActivity.this.shouldShowTabs();
+ return hasWorkProfile();
}
@Override
@@ -1212,13 +2016,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
showTargetDetails(longPressedTargetInfo);
}
}
-
- @Override
- public void updateProfileViewButton(View newButtonFromProfileRow) {
- mProfileView = newButtonFromProfileRow;
- mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick);
- ChooserActivity.this.updateProfileViewButton();
- }
},
chooserListAdapter,
shouldShowContentPreview(),
@@ -1237,8 +2034,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
UserHandle userHandle,
Intent targetIntent,
Intent referrerFillInIntent,
- int maxTargetsPerRow,
- TargetDataLoader targetDataLoader) {
+ int maxTargetsPerRow) {
UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile()
&& userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle)
? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle;
@@ -1253,30 +2049,35 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
targetIntent,
referrerFillInIntent,
this,
- context.getPackageManager(),
+ mPackageManager,
getEventLog(),
maxTargetsPerRow,
initialIntentsUserSpace,
- targetDataLoader,
+ mTargetDataLoader,
() -> {
ProfileRecord record = getProfileRecord(userHandle);
if (record != null && record.shortcutLoader != null) {
record.shortcutLoader.reset();
}
- });
+ },
+ mFeatureFlags);
}
- @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();
+ if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals(
+ requireAnnotatedUserHandles().workProfileUserHandle)) {
+ mChooserMultiProfilePagerAdapter.rebuildActiveTab(true);
+ } else {
+ mChooserMultiProfilePagerAdapter.clearInactiveProfileCache();
+ }
+ return Unit.INSTANCE;
}
- @Override
@VisibleForTesting
protected ChooserListController createListController(UserHandle userHandle) {
AppPredictor appPredictor = getAppPredictor(userHandle);
@@ -1284,8 +2085,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (appPredictor != null) {
resolverComparator = new AppPredictionServiceResolverComparator(
this,
- mLogic.getTargetIntent(),
- mLogic.getReferrerPackageName(),
+ mViewModel.getChooserRequest().getTargetIntent(),
+ mViewModel.getChooserRequest().getLaunchedFromPackage(),
appPredictor,
userHandle,
getEventLog(),
@@ -1295,8 +2096,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
resolverComparator =
new ResolverRankerServiceResolverComparator(
this,
- mLogic.getTargetIntent(),
- mLogic.getReferrerPackageName(),
+ mViewModel.getChooserRequest().getTargetIntent(),
+ mViewModel.getChooserRequest().getReferrerPackage(),
null,
getEventLog(),
getResolverRankerServiceUserHandleList(userHandle),
@@ -1305,12 +2106,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return new ChooserListController(
this,
- mPm,
- mLogic.getTargetIntent(),
- mLogic.getReferrerPackageName(),
+ mPackageManager,
+ mViewModel.getChooserRequest().getTargetIntent(),
+ mViewModel.getChooserRequest().getReferrerPackage(),
requireAnnotatedUserHandles().userIdOfCallingApp,
resolverComparator,
- getQueryIntentsUser(userHandle));
+ getQueryIntentsUser(userHandle),
+ mViewModel.getChooserRequest().getFilteredComponentNames(),
+ mPinnedSharedPrefs);
}
@VisibleForTesting
@@ -1319,11 +2122,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private ChooserActionFactory createChooserActionFactory() {
- ChooserRequestParameters request = requireChooserRequest();
+ ChooserRequest request = mViewModel.getChooserRequest();
return new ChooserActionFactory(
this,
request.getTargetIntent(),
- request.getReferrerPackageName(),
+ request.getLaunchedFromPackage(),
request.getChooserActions(),
request.getModifyShareAction(),
mImageEditor,
@@ -1354,12 +2157,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mFinishWhenStopped = true;
}
},
+ mShareResultSender,
(status) -> {
if (status != null) {
setResult(status);
}
finish();
- });
+ },
+ mClipboardManager);
}
/*
@@ -1404,8 +2209,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
updateTabPadding();
}
- UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle();
- int currentProfile = getProfileForUser(currentUserHandle);
+ int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile();
int initialProfile = findSelectedProfile();
if (currentProfile != initialProfile) {
return;
@@ -1432,7 +2236,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0;
int rowsToShow = gridAdapter.getSystemRowCount()
- + gridAdapter.getProfileRowCount()
+ gridAdapter.getServiceTargetRowCount()
+ gridAdapter.getCallerAndRankedTargetRowCount();
@@ -1455,7 +2258,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
offset += stickyContentPreview.getHeight();
}
- if (shouldShowTabs()) {
+ if (hasWorkProfile()) {
offset += findViewById(com.android.internal.R.id.tabs).getHeight();
}
@@ -1512,7 +2315,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return PROFILE_PERSONAL;
}
- @Override
protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) {
setupScrollListener();
maybeSetupGlobalLayoutListener();
@@ -1582,7 +2384,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
adapter.completeServiceTargetLoading();
}
- if (mMultiProfilePagerAdapter.getActiveListAdapter() == adapter) {
+ if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) {
long duration = Tracer.INSTANCE.endLaunchToShortcutTrace();
if (duration >= 0) {
Log.d(TAG, "stat to first shortcut time: " + duration + " ms");
@@ -1597,7 +2399,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (mResolverDrawerLayout == null) {
return;
}
- int elevatedViewResId = shouldShowTabs() ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header;
+ int elevatedViewResId = hasWorkProfile() ?
+ 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 =
@@ -1635,7 +2438,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private void maybeSetupGlobalLayoutListener() {
- if (shouldShowTabs()) {
+ if (hasWorkProfile()) {
return;
}
final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView();
@@ -1669,9 +2472,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (!shouldShowContentPreview()) {
return false;
}
- boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle(
+ boolean isEmpty = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(
UserHandle.of(UserHandle.myUserId())).getCount() == 0;
- return (mFeatureFlags.scrollablePreview() || shouldShowTabs())
+ return (mFeatureFlags.scrollablePreview() || hasWorkProfile())
&& (!isEmpty || shouldShowContentPreviewWhenEmpty());
}
@@ -1690,7 +2493,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
* @return true if we want to show the content preview area
*/
protected boolean shouldShowContentPreview() {
- ChooserRequestParameters chooserRequest = getChooserRequest();
+ ChooserRequest chooserRequest = mViewModel.getChooserRequest();
return (chooserRequest != null) && chooserRequest.isSendActionTarget();
}
@@ -1735,34 +2538,22 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
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() {
+ protected void onProfileTabSelected(int currentPage) {
+ setupViewVisibilities();
+ maybeLogProfileChange();
+ if (hasWorkProfile()) {
+ // The device policy logger is only concerned with sessions that include a work profile.
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS)
+ .setInt(currentPage)
+ .setStrings(getMetricsCategory())
+ .write();
+ }
+
// 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
@@ -1772,14 +2563,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
}
- @Override
protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
- if (shouldShowTabs()) {
+ if (hasWorkProfile()) {
mChooserMultiProfilePagerAdapter
.setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom());
}
- WindowInsets result = super.onApplyWindowInsets(v, insets);
+ WindowInsets result = super_onApplyWindowInsets(v, insets);
if (mResolverDrawerLayout != null) {
mResolverDrawerLayout.requestLayout();
}
@@ -1798,7 +2588,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
layoutManager.setVerticalScrollEnabled(enabled);
}
- @Override
void onHorizontalSwipeStateChanged(int state) {
if (state == ViewPager.SCROLL_STATE_DRAGGING) {
if (mScrollStatus == SCROLL_STATUS_IDLE) {
@@ -1813,7 +2602,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
}
- @Override
protected void maybeLogProfileChange() {
getEventLog().logSharesheetProfileChanged();
}
diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt
index 7bc39a24..84b7d9a9 100644
--- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt
+++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt
@@ -1,87 +1,23 @@
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.
+ * [chooserRequest]. For now, this class being open is better than using reflection there.
*/
@OpenForTesting
open class ChooserActivityLogic(
tag: String,
- activityProvider: () -> ComponentActivity,
+ activity: ComponentActivity,
onWorkProfileStatusUpdated: () -> Unit,
- targetDataLoaderProvider: () -> TargetDataLoader,
- private val onPreInitialization: () -> Unit,
) :
ActivityLogic,
CommonActivityLogic by CommonActivityLogicImpl(
tag,
- activityProvider,
+ activity,
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/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt
new file mode 100644
index 00000000..17bc2731
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2024 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 androidx.activity.ComponentActivity
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import dagger.hilt.android.scopes.ActivityScoped
+import javax.inject.Inject
+
+/**
+ * __Purpose__
+ *
+ * Cleanup aid. Provides a pathway to cleaner code.
+ *
+ * __Incoming References__
+ *
+ * For use by ChooserActivity only; must not be accessed by any code outside of ChooserActivity.
+ * This prevents circular dependencies and coupling, and maintains unidirectional flow. This is
+ * important for maintaining a migration path towards healthier architecture.
+ *
+ * __Outgoing References__
+ *
+ * _ChooserActivity_
+ *
+ * This class must only reference it's host as Activity/ComponentActivity; no down-cast to
+ * [ChooserActivity]. Other components should be passed in and not pulled from other places. This
+ * prevents circular dependencies from forming.
+ *
+ * _Elsewhere_
+ *
+ * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of
+ * referenced from an existing location. If not available for injection, the value should be
+ * constructed here, then provided to where it is needed. If existing objects from ChooserActivity
+ * are required, supply a factory interface which satisfies the necessary dependencies and use it
+ * during construction.
+ */
+
+@ActivityScoped
+class ChooserHelper @Inject constructor(
+ hostActivity: Activity,
+) : DefaultLifecycleObserver {
+ // This is guaranteed by Hilt, since only a ComponentActivity is injectable.
+ private val activity: ComponentActivity = hostActivity as ComponentActivity
+
+ private var activityPostCreate: Runnable? = null
+
+ init {
+ activity.lifecycle.addObserver(this)
+ }
+
+ /**
+ * Provides a optional callback to setup state which is not yet possible to do without circular
+ * dependencies or by moving more code.
+ */
+ fun setPostCreateCallback(onPostCreate: Runnable) {
+ activityPostCreate = onPostCreate
+ }
+
+ /**
+ * Invoked by Lifecycle, after Activity.onCreate() _returns_.
+ */
+ override fun onCreate(owner: LifecycleOwner) {
+ activityPostCreate?.run()
+ }
+} \ No newline at end of file
diff --git a/java/src/com/android/intentresolver/v2/ChooserListController.java b/java/src/com/android/intentresolver/v2/ChooserListController.java
new file mode 100644
index 00000000..467f343b
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ChooserListController.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2024 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.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.os.UserHandle;
+
+import com.android.intentresolver.ResolverListController;
+import com.android.intentresolver.model.AbstractResolverComparator;
+
+import java.util.List;
+
+public class ChooserListController extends ResolverListController {
+ private final List<ComponentName> mFilteredComponents;
+ private final SharedPreferences mPinnedComponents;
+
+ public ChooserListController(
+ Context context,
+ PackageManager pm,
+ Intent targetIntent,
+ String referrerPackageName,
+ int launchedFromUid,
+ AbstractResolverComparator resolverComparator,
+ UserHandle queryIntentsAsUser,
+ List<ComponentName> filteredComponents,
+ SharedPreferences pinnedComponents) {
+ super(
+ context,
+ pm,
+ targetIntent,
+ referrerPackageName,
+ launchedFromUid,
+ resolverComparator,
+ queryIntentsAsUser);
+ mFilteredComponents = filteredComponents;
+ mPinnedComponents = pinnedComponents;
+ }
+
+ @Override
+ public boolean isComponentFiltered(ComponentName name) {
+ return mFilteredComponents.contains(name);
+ }
+
+ @Override
+ public boolean isComponentPinned(ComponentName name) {
+ return mPinnedComponents.getBoolean(name.flattenToString(), false);
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt
new file mode 100644
index 00000000..2f8ccf77
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 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.service.chooser.ChooserAction
+import com.android.intentresolver.contentpreview.ChooserContentPreviewUi
+import com.android.intentresolver.contentpreview.MutableActionFactory
+import com.android.intentresolver.widget.ActionRow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/** A wrapper around [ChooserActionFactory] that provides observable custom actions */
+class ChooserMutableActionFactory(
+ private val actionFactory: ChooserActionFactory,
+) : MutableActionFactory, ChooserContentPreviewUi.ActionFactory by actionFactory {
+ private val customActions =
+ MutableStateFlow<List<ActionRow.Action>>(actionFactory.createCustomActions())
+
+ override val customActionsFlow: Flow<List<ActionRow.Action>>
+ get() = customActions
+
+ override fun updateCustomActions(actions: List<ChooserAction>) {
+ customActions.tryEmit(mapChooserActions(actions))
+ }
+
+ override fun createCustomActions(): List<ActionRow.Action> = customActions.value
+
+ private fun mapChooserActions(chooserActions: List<ChooserAction>): List<ActionRow.Action> =
+ buildList(chooserActions.size) {
+ chooserActions.forEachIndexed { i, chooserAction ->
+ val actionRow =
+ actionFactory.createCustomAction(chooserAction) {
+ actionFactory.logCustomAction(i)
+ }
+ if (actionRow != null) {
+ add(actionRow)
+ }
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/IntentForwarding.kt b/java/src/com/android/intentresolver/v2/IntentForwarding.kt
new file mode 100644
index 00000000..3d366d10
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/IntentForwarding.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2024 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.Manifest
+import android.Manifest.permission.INTERACT_ACROSS_USERS
+import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
+import android.app.ActivityManager
+import android.content.Context
+import android.content.Intent
+import android.content.PermissionChecker
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.os.UserHandle
+import android.os.UserManager
+import android.util.Log
+import com.android.intentresolver.v2.data.repository.DevicePolicyResources
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val TAG: String = "IntentForwarding"
+
+@Singleton
+class IntentForwarding
+@Inject
+constructor(
+ private val resources: DevicePolicyResources,
+ private val userManager: UserManager,
+ private val packageManager: PackageManager
+) {
+
+ 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 -> resources.forwardToPersonalMessage
+ !originIsManaged && targetIsManaged -> resources.forwardToWorkMessage
+ else -> null
+ }
+ }
+ return null
+ }
+
+ private fun isPermissionGranted(permission: String, uid: Int) =
+ ActivityManager.checkComponentPermission(
+ /* permission = */ permission,
+ /* uid = */ uid,
+ /* owningUid= */ -1,
+ /* exported= */ true
+ )
+
+ /**
+ * Returns whether the package has the necessary permissions to interact across profiles on
+ * behalf of a given user.
+ *
+ * This means meeting the following condition:
+ * * The app's [ApplicationInfo.crossProfile] flag must be true, and at least one of the
+ * following conditions must be fulfilled
+ * * `Manifest.permission.INTERACT_ACROSS_USERS_FULL` granted.
+ * * `Manifest.permission.INTERACT_ACROSS_USERS` granted.
+ * * `Manifest.permission.INTERACT_ACROSS_PROFILES` granted, or the corresponding AppOps
+ * `android:interact_across_profiles` is set to "allow".
+ */
+ fun canAppInteractAcrossProfiles(context: Context, packageName: String): Boolean {
+ val applicationInfo: ApplicationInfo
+ try {
+ applicationInfo = packageManager.getApplicationInfo(packageName, 0)
+ } catch (e: PackageManager.NameNotFoundException) {
+ Log.e(TAG, "Package $packageName does not exist on current user.")
+ return false
+ }
+ if (!applicationInfo.crossProfile) {
+ return false
+ }
+
+ val packageUid = applicationInfo.uid
+
+ if (isPermissionGranted(INTERACT_ACROSS_USERS_FULL, packageUid) == PERMISSION_GRANTED) {
+ return true
+ }
+ if (isPermissionGranted(INTERACT_ACROSS_USERS, packageUid) == PERMISSION_GRANTED) {
+ return true
+ }
+ return PermissionChecker.checkPermissionForPreflight(
+ context,
+ Manifest.permission.INTERACT_ACROSS_PROFILES,
+ PermissionChecker.PID_UNKNOWN,
+ packageUid,
+ packageName
+ ) == PERMISSION_GRANTED
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt
new file mode 100644
index 00000000..c6c977f6
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2024 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("JavaFlowHelper")
+
+package com.android.intentresolver.v2
+
+import java.util.function.Consumer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+
+fun <T> collect(scope: CoroutineScope, flow: Flow<T>, collector: Consumer<T>): Job =
+ scope.launch { flow.collect { collector.accept(it) } }
diff --git a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt
new file mode 100644
index 00000000..4d689724
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2024 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 com.android.intentresolver.v2.domain.interactor.UserInteractor
+import com.android.intentresolver.v2.shared.model.Profile
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+
+/** Provides availability status for profiles */
+class ProfileAvailability(
+ private val scope: CoroutineScope,
+ private val userInteractor: UserInteractor
+) {
+ private val availability =
+ userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, mapOf())
+
+ /** Used by WorkProfilePausedEmptyStateProvider */
+ var waitingToEnableProfile = false
+ private set
+
+ private var waitJob: Job? = null
+ /** Query current profile availability. An unavailable profile is one which is not active. */
+ fun isAvailable(profile: Profile) = availability.value[profile] ?: false
+
+ /** Used by WorkProfilePausedEmptyStateProvider */
+ fun requestQuietModeState(profile: Profile, quietMode: Boolean) {
+ val enableProfile = !quietMode
+
+ // Check if the profile is already in the correct state
+ if (isAvailable(profile) == enableProfile) {
+ return // No-op
+ }
+
+ // Support existing code
+ if (enableProfile) {
+ waitingToEnableProfile = true
+ waitJob?.cancel()
+
+ val job = scope.launch {
+ // Wait for the profile to become available
+ // Wait for the profile to be enabled, then clear this flag
+ userInteractor.availability.filter { it[profile] == true }.first()
+ waitingToEnableProfile = false
+ }
+ job.invokeOnCompletion {
+ waitingToEnableProfile = false
+ }
+ waitJob = job
+ }
+
+ // Apply the change
+ scope.launch { userInteractor.updateState(profile, enableProfile) }
+ }
+} \ No newline at end of file
diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt
new file mode 100644
index 00000000..784096b4
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ProfileHelper.kt
@@ -0,0 +1,74 @@
+/*
+* Copyright (C) 2024 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.os.UserHandle
+import com.android.intentresolver.inject.IntentResolverFlags
+import com.android.intentresolver.v2.domain.interactor.UserInteractor
+import com.android.intentresolver.v2.shared.model.Profile
+import com.android.intentresolver.v2.shared.model.User
+import javax.inject.Inject
+
+class ProfileHelper @Inject constructor(
+ interactor: UserInteractor,
+ private val flags: IntentResolverFlags,
+ profiles: List<Profile>,
+ launchedAsProfile: Profile,
+) {
+ private val launchedByHandle: UserHandle = interactor.launchedAs
+
+ // Map UserHandle back to a user within launchedByProfile
+ private val launchedByUser = when (launchedByHandle) {
+ launchedAsProfile.primary.handle -> launchedAsProfile.primary
+ launchedAsProfile.clone?.handle -> launchedAsProfile.clone
+ else -> error("launchedByUser must be a member of launchedByProfile")
+ }
+ val launchedAsProfileType: Profile.Type = launchedAsProfile.type
+
+ val personalProfile = profiles.single { it.type == Profile.Type.PERSONAL }
+ val workProfile = profiles.singleOrNull { it.type == Profile.Type.WORK }
+ val privateProfile = profiles.singleOrNull { it.type == Profile.Type.PRIVATE }
+
+ val personalHandle = personalProfile.primary.handle
+ val workHandle = workProfile?.primary?.handle
+ val privateHandle = privateProfile?.primary?.handle?.takeIf { flags.enablePrivateProfile() }
+ val cloneHandle = personalProfile.clone?.handle
+
+ val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone
+
+ val cloneUserPresent = personalProfile.clone != null
+ val workProfilePresent = workProfile != null
+ val privateProfilePresent = privateProfile != null
+
+ // Name retained for ease of review, to be renamed later
+ val tabOwnerUserHandleForLaunch = if (launchedByUser.role == User.Role.CLONE) {
+ // When started by clone user, return the profile owner instead
+ launchedAsProfile.primary.handle
+ } else {
+ // Otherwise the launched user is used
+ launchedByUser.handle
+ }
+
+ // Name retained for ease of review, to be renamed later
+ fun getQueryIntentsHandle(handle: UserHandle): UserHandle? {
+ return if (isLaunchedAsCloneProfile && handle == personalHandle) {
+ cloneHandle
+ } else {
+ handle
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java
index 2ba50ec3..0182fc89 100644
--- a/java/src/com/android/intentresolver/v2/ResolverActivity.java
+++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java
@@ -16,34 +16,29 @@
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.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs;
+import static com.android.intentresolver.v2.ui.viewmodel.ResolverRequestReaderKt.readResolverRequest;
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;
@@ -51,7 +46,6 @@ 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;
@@ -83,15 +77,13 @@ 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.lifecycle.viewmodel.CreationExtras;
import androidx.viewpager.widget.ViewPager;
import com.android.intentresolver.AnnotatedUserHandles;
@@ -105,24 +97,40 @@ 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.DefaultTargetDataLoader;
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.emptystate.ResolverWorkProfilePausedEmptyStateProvider;
+import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter;
+import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType;
+import com.android.intentresolver.v2.profiles.OnProfileSelectedListener;
+import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener;
+import com.android.intentresolver.v2.profiles.ResolverMultiProfilePagerAdapter;
+import com.android.intentresolver.v2.profiles.TabConfig;
+import com.android.intentresolver.v2.shared.model.Profile;
import com.android.intentresolver.v2.ui.ActionTitle;
+import com.android.intentresolver.v2.ui.model.ActivityModel;
+import com.android.intentresolver.v2.ui.model.ResolverRequest;
+import com.android.intentresolver.v2.validation.Finding;
+import com.android.intentresolver.v2.validation.FindingsKt;
+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.widget.ResolverDrawerLayout;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.content.PackageMonitor;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto;
-import com.android.internal.util.LatencyTracker;
+import com.google.common.collect.ImmutableList;
+
+import dagger.hilt.android.AndroidEntryPoint;
+
+import kotlin.Pair;
import kotlin.Unit;
import java.util.ArrayList;
@@ -131,6 +139,9 @@ import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
+import java.util.function.Consumer;
+
+import javax.inject.Inject;
/**
* This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is
@@ -138,23 +149,18 @@ import java.util.Set;
* 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
+@AndroidEntryPoint(FragmentActivity.class)
+public class ResolverActivity extends Hilt_ResolverActivity implements
ResolverListAdapter.ResolverListCommunicator {
- private final List<Runnable> mInit = new ArrayList<>();
-
+ @Inject public PackageManager mPackageManager;
+ @Inject public DevicePolicyResources mDevicePolicyResources;
+ @Inject public IntentForwarding mIntentForwarding;
+ private ResolverRequest mResolverRequest;
+ private ActivityModel mActivityModel;
protected ActivityLogic mLogic;
-
- private DevicePolicyResources mDevicePolicyResources;
-
- public ResolverActivity() {
- mIsIntentPicker = getClass().equals(ResolverActivity.class);
- }
-
- protected ResolverActivity(boolean isIntentPicker) {
- mIsIntentPicker = isIntentPicker;
- }
+ protected TargetDataLoader mTargetDataLoader;
+ private boolean mResolvingHome;
private Button mAlwaysButton;
private Button mOnceButton;
@@ -163,9 +169,7 @@ public class ResolverActivity extends FragmentActivity implements
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;
@@ -176,64 +180,33 @@ public class ResolverActivity extends FragmentActivity implements
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 final boolean mWorkProfileHasBeenEnabled = false;
- private static final String TAB_TAG_PERSONAL = "personal";
- private static final String TAB_TAG_WORK = "work";
+ protected static final String TAB_TAG_PERSONAL = "personal";
+ protected static final String TAB_TAG_WORK = "work";
private PackageMonitor mPersonalPackageMonitor;
private PackageMonitor mWorkPackageMonitor;
- @VisibleForTesting
- protected MultiProfilePagerAdapter mMultiProfilePagerAdapter;
-
+ protected ResolverMultiProfilePagerAdapter 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;
+ public static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL;
+ public 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
@@ -244,65 +217,63 @@ public class ResolverActivity extends FragmentActivity implements
}
};
}
- protected interface Initializer {
- void initialize(ActivityLogic value);
+ protected ActivityModel createActivityModel() {
+ return ActivityModel.createFrom(this);
}
- protected void setLogic(ActivityLogic logic) {
- mLogic = logic;
+ @VisibleForTesting
+ protected ActivityLogic createActivityLogic() {
+ return new ResolverActivityLogic(
+ TAG,
+ /* activity = */ this,
+ this::onWorkProfileStatusUpdated);
}
- protected void addInitializer(Runnable initializer) {
- mInit.add(initializer);
+ @NonNull
+ @Override
+ public CreationExtras getDefaultViewModelCreationExtras() {
+ return addDefaultArgs(
+ super.getDefaultViewModelCreationExtras(),
+ new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, ActivityModel.createFrom(this)));
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- if (isFinishing()) {
- // Performing a clean exit:
- // Skip initializing anything.
- return;
+ setTheme(R.style.Theme_DeviceDefault_Resolver);
+ mActivityModel = createActivityModel();
+
+ Log.i(TAG, "onCreate");
+ Log.i(TAG, "activityModel=" + mActivityModel.toString());
+ int callerUid = mActivityModel.getLaunchedFromUid();
+ if (callerUid < 0 || UserHandle.isIsolated(callerUid)) {
+ Log.e(TAG, "Can't start a resolver from uid " + callerUid);
+ finish();
}
- 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();
+ ValidationResult<ResolverRequest> result = readResolverRequest(mActivityModel);
+ if (result instanceof Invalid) {
+ ((Invalid) result).getErrors().forEach(new Consumer<Finding>() {
+ @Override
+ public void accept(Finding finding) {
+ FindingsKt.log(finding, TAG);
+ }
+ });
+ finish();
}
+ mResolverRequest = ((Valid<ResolverRequest>) result).getValue();
+ mLogic = createActivityLogic();
+ mResolvingHome = mResolverRequest.isResolvingHome();
+ mTargetDataLoader = new DefaultTargetDataLoader(
+ this,
+ getLifecycle(),
+ mResolverRequest.isAudioCaptureDevice());
+ init();
+ restore(savedInstanceState);
}
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();
+ Intent intent = mResolverRequest.getIntent();
// 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
@@ -312,15 +283,14 @@ public class ResolverActivity extends FragmentActivity implements
// 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();
+ boolean filterLastUsed = !isVoiceInteraction()
+ && !hasWorkProfile() && !hasCloneProfile();
mMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
- requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]),
- /* resolutionList = */ null,
- filterLastUsed,
- targetDataLoader
+ new Intent[0],
+ /* resolutionList = */ mResolverRequest.getResolutionList(),
+ filterLastUsed
);
- if (configureContentView(targetDataLoader)) {
+ if (configureContentView(mTargetDataLoader)) {
return;
}
@@ -354,7 +324,7 @@ public class ResolverActivity extends FragmentActivity implements
}
});
- boolean hasTouchScreen = getPackageManager()
+ boolean hasTouchScreen = mPackageManager
.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN);
if (isVoiceInteraction() || !hasTouchScreen) {
@@ -368,12 +338,6 @@ public class ResolverActivity extends FragmentActivity implements
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
@@ -382,19 +346,31 @@ public class ResolverActivity extends FragmentActivity implements
+ (categories != null ? Arrays.toString(categories.toArray()) : ""));
}
- protected MultiProfilePagerAdapter createMultiProfilePagerAdapter(
+ private void restore(@Nullable Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ // onRestoreInstanceState
+ resetButtonBar();
+ ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+ if (viewPager != null) {
+ viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY));
+ }
+ }
+
+ mMultiProfilePagerAdapter.clearInactiveProfileCache();
+ }
+
+ protected ResolverMultiProfilePagerAdapter createMultiProfilePagerAdapter(
Intent[] initialIntents,
List<ResolveInfo> resolutionList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
- MultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null;
- if (shouldShowTabs()) {
+ boolean filterLastUsed) {
+ ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null;
+ if (hasWorkProfile()) {
resolverMultiProfilePagerAdapter =
createResolverMultiProfilePagerAdapterForTwoProfiles(
- initialIntents, resolutionList, filterLastUsed, targetDataLoader);
+ initialIntents, resolutionList, filterLastUsed);
} else {
resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile(
- initialIntents, resolutionList, filterLastUsed, targetDataLoader);
+ initialIntents, resolutionList, filterLastUsed);
}
return resolverMultiProfilePagerAdapter;
}
@@ -448,9 +424,7 @@ public class ResolverActivity extends FragmentActivity implements
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;
+ return buttonBar == null || buttonBar.getVisibility() == View.GONE;
}
protected void applyFooterView(int height) {
@@ -492,7 +466,7 @@ public class ResolverActivity extends FragmentActivity implements
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
- if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault()
+ if (hasWorkProfile() && !useLayoutWithDefault()
&& !shouldUseMiniResolver()) {
updateIntentPickerPaddings();
}
@@ -525,7 +499,7 @@ public class ResolverActivity extends FragmentActivity implements
}
final Intent intent = getIntent();
if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction()
- && !mLogic.getResolvingHome() && !mRetainInOnStop) {
+ && !mResolvingHome) {
// 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
@@ -553,6 +527,7 @@ public class ResolverActivity extends FragmentActivity implements
}
}
+ // referenced by layout XML: android:onClick="onButtonClick"
public void onButtonClick(View v) {
final int id = v.getId();
ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView();
@@ -570,8 +545,8 @@ public class ResolverActivity extends FragmentActivity implements
}
ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter()
.resolveInfoForPosition(which, hasIndexBeenFiltered);
- if (mLogic.getResolvingHome() && hasManagedProfile() && !supportsManagedProfiles(ri)) {
- String launcherName = ri.activityInfo.loadLabel(getPackageManager()).toString();
+ if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) {
+ String launcherName = ri.activityInfo.loadLabel(mPackageManager).toString();
Toast.makeText(this,
mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName),
Toast.LENGTH_LONG).show();
@@ -584,15 +559,12 @@ public class ResolverActivity extends FragmentActivity implements
return;
}
if (onTargetSelected(target, always)) {
- if (always && mLogic.getSupportsAlwaysUseOption()) {
+ if (always) {
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);
+ this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE);
}
MetricsLogger.action(this,
mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()
@@ -602,9 +574,6 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- /**
- * Replace me in subclasses!
- */
@Override // ResolverListCommunicator
public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) {
return defIntent;
@@ -613,7 +582,7 @@ public class ResolverActivity extends FragmentActivity implements
protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) {
final ItemClickListener listener = new ItemClickListener();
setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener);
- if (shouldShowTabs() && mIsIntentPicker) {
+ if (hasWorkProfile()) {
final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel);
if (rdl != null) {
rdl.setMaxCollapsedHeight(getResources()
@@ -628,8 +597,7 @@ public class ResolverActivity extends FragmentActivity implements
final ResolveInfo ri = target.getResolveInfo();
final Intent intent = target != null ? target.getResolvedIntent() : null;
- if (intent != null && (mLogic.getSupportsAlwaysUseOption()
- || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem())
+ if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/
&& mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) {
// Build a reasonable intent filter, based on what matched.
IntentFilter filter = new IntentFilter();
@@ -672,7 +640,7 @@ public class ResolverActivity extends FragmentActivity implements
// or "content:" schemes (see IntentFilter for the reason).
if (cat != IntentFilter.MATCH_CATEGORY_TYPE
|| (!"file".equals(data.getScheme())
- && !"content".equals(data.getScheme()))) {
+ && !"content".equals(data.getScheme()))) {
filter.addDataScheme(data.getScheme());
// Look through the resolved filter to determine which part
@@ -730,7 +698,7 @@ public class ResolverActivity extends FragmentActivity implements
}
int bestMatch = 0;
- for (int i=0; i<N; i++) {
+ for (int i = 0; i < N; i++) {
ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter()
.getUnfilteredResolveList().get(i).getResolveInfoAt(0);
set[i] = new ComponentName(r.activityInfo.packageName,
@@ -748,7 +716,7 @@ public class ResolverActivity extends FragmentActivity implements
if (always) {
final int userId = getUserId();
- final PackageManager pm = getPackageManager();
+ final PackageManager pm = mPackageManager;
// Set the preferred Activity
pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent());
@@ -757,7 +725,8 @@ public class ResolverActivity extends FragmentActivity implements
// Set default Browser if needed
final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId);
if (TextUtils.isEmpty(packageName)) {
- pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId);
+ pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName,
+ userId);
}
}
} else {
@@ -771,21 +740,11 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- 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;
- }
+ safelyStartActivity(target);
- public void onActivityStarted(TargetInfo cti) {
- // Do nothing
+ // Rely on the ActivityManager to pop up a dialog regarding app suspension
+ // and return false
+ return !target.isSuspended();
}
@Override // ResolverListCommunicator
@@ -797,34 +756,23 @@ public class ResolverActivity extends FragmentActivity implements
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(),
+ mResolverRequest.getIntent(),
+ mActivityModel.getReferrerPackage(),
null,
null,
getResolverRankerServiceUserHandleList(userHandle),
null);
return new ResolverListController(
this,
- mPm,
- mLogic.getTargetIntent(),
- mLogic.getReferrerPackageName(),
- requireAnnotatedUserHandles().userIdOfCallingApp,
+ mPackageManager,
+ mActivityModel.getIntent(),
+ mActivityModel.getReferrerPackage(),
+ mActivityModel.getLaunchedFromUid(),
resolverComparator,
getQueryIntentsUser(userHandle));
}
@@ -839,13 +787,30 @@ public class ResolverActivity extends FragmentActivity implements
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() { }
+ /* TODO: consider merging with the customized considerations of our implemented
+ * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions
+ * between the respective listener callbacks would occur in the triggering patterns during init
+ * (when the `OnProfileSelectedListener` is registered after a possible tab-change), or possibly
+ * if there's some way to trigger an update in one model but not the other. If there's an
+ * initialization dependency, we can probably reason about it with confidence. If there's a
+ * discrepancy between the `TabHost` and pager-adapter data models, that inconsistency is
+ * likely to be a bug that would benefit from consolidation.
+ */
+ protected void onProfileTabSelected(int currentPage) {
+ setupViewVisibilities();
+ maybeLogProfileChange();
+ if (hasWorkProfile()) {
+ // The device policy logger is only concerned with sessions that include a work profile.
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS)
+ .setInt(currentPage)
+ .setStrings(getMetricsCategory())
+ .write();
+ }
+ }
/**
* Add a label to signify that the user can pick a different app.
@@ -858,7 +823,7 @@ public class ResolverActivity extends FragmentActivity implements
stub.setVisibility(View.VISIBLE);
TextView textView = (TextView) LayoutInflater.from(this).inflate(
R.layout.resolver_different_item_header, null, false);
- if (shouldShowTabs()) {
+ if (hasWorkProfile()) {
textView.setGravity(Gravity.CENTER);
}
stub.addView(textView);
@@ -866,9 +831,6 @@ public class ResolverActivity extends FragmentActivity implements
}
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");
@@ -921,21 +883,13 @@ public class ResolverActivity extends FragmentActivity implements
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)) {
+ if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) {
mMultiProfilePagerAdapter.rebuildActiveTab(true);
} else {
mMultiProfilePagerAdapter.clearInactiveProfileCache();
@@ -951,8 +905,7 @@ public class ResolverActivity extends FragmentActivity implements
Intent[] initialIntents,
List<ResolveInfo> resolutionList,
boolean filterLastUsed,
- UserHandle userHandle,
- TargetDataLoader targetDataLoader) {
+ UserHandle userHandle) {
UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile()
&& userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle)
? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle;
@@ -964,26 +917,10 @@ public class ResolverActivity extends FragmentActivity implements
filterLastUsed,
createListController(userHandle),
userHandle,
- mLogic.getTargetIntent(),
+ mResolverRequest.getIntent(),
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;
+ mTargetDataLoader);
}
protected final EmptyStateProvider createEmptyStateProvider(
@@ -991,7 +928,7 @@ public class ResolverActivity extends FragmentActivity implements
final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider();
final EmptyStateProvider workProfileOffEmptyStateProvider =
- new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle,
+ new ResolverWorkProfilePausedEmptyStateProvider(this, workProfileUserHandle,
mLogic.getWorkProfileAvailabilityManager(),
/* onSwitchOnWorkSelectedListener= */
() -> {
@@ -1021,36 +958,40 @@ public class ResolverActivity extends FragmentActivity implements
createResolverMultiProfilePagerAdapterForOneProfile(
Intent[] initialIntents,
List<ResolveInfo> resolutionList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
- ResolverListAdapter adapter = createResolverListAdapter(
+ boolean filterLastUsed) {
+ ResolverListAdapter personalAdapter = createResolverListAdapter(
/* context */ this,
- mLogic.getPayloadIntents(),
+ mResolverRequest.getPayloadIntents(),
initialIntents,
resolutionList,
filterLastUsed,
- /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle,
- targetDataLoader);
+ /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle
+ );
return new ResolverMultiProfilePagerAdapter(
/* context */ this,
- adapter,
+ ImmutableList.of(
+ new TabConfig<>(
+ PROFILE_PERSONAL,
+ mDevicePolicyResources.getPersonalTabLabel(),
+ mDevicePolicyResources.getPersonalTabAccessibilityLabel(),
+ TAB_TAG_PERSONAL,
+ personalAdapter)),
createEmptyStateProvider(/* workProfileUserHandle= */ null),
/* workProfileQuietModeChecker= */ () -> false,
+ /* defaultProfile= */ PROFILE_PERSONAL,
/* workProfileUserHandle= */ null,
requireAnnotatedUserHandles().cloneProfileUserHandle);
}
private UserHandle getIntentUser() {
- return getIntent().hasExtra(EXTRA_CALLING_USER)
- ? getIntent().getParcelableExtra(EXTRA_CALLING_USER)
- : requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch;
+ return Objects.requireNonNullElse(mResolverRequest.getCallingUser(),
+ requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch);
}
private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles(
Intent[] initialIntents,
List<ResolveInfo> resolutionList,
- boolean filterLastUsed,
- TargetDataLoader targetDataLoader) {
+ boolean filterLastUsed) {
// 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.
@@ -1073,27 +1014,38 @@ public class ResolverActivity extends FragmentActivity implements
// resolver list. So filterLastUsed should be false for the other profile.
ResolverListAdapter personalAdapter = createResolverListAdapter(
/* context */ this,
- mLogic.getPayloadIntents(),
+ mResolverRequest.getPayloadIntents(),
selectedProfile == PROFILE_PERSONAL ? initialIntents : null,
resolutionList,
(filterLastUsed && UserHandle.myUserId()
== requireAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()),
- /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle,
- targetDataLoader);
+ /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle
+ );
UserHandle workProfileUserHandle = requireAnnotatedUserHandles().workProfileUserHandle;
ResolverListAdapter workAdapter = createResolverListAdapter(
/* context */ this,
- mLogic.getPayloadIntents(),
+ mResolverRequest.getPayloadIntents(),
selectedProfile == PROFILE_WORK ? initialIntents : null,
resolutionList,
(filterLastUsed && UserHandle.myUserId()
== workProfileUserHandle.getIdentifier()),
- /* userHandle */ workProfileUserHandle,
- targetDataLoader);
+ /* userHandle */ workProfileUserHandle
+ );
return new ResolverMultiProfilePagerAdapter(
/* context */ this,
- personalAdapter,
- workAdapter,
+ ImmutableList.of(
+ new TabConfig<>(
+ PROFILE_PERSONAL,
+ mDevicePolicyResources.getPersonalTabLabel(),
+ mDevicePolicyResources.getPersonalTabAccessibilityLabel(),
+ TAB_TAG_PERSONAL,
+ personalAdapter),
+ new TabConfig<>(
+ PROFILE_WORK,
+ mDevicePolicyResources.getWorkTabLabel(),
+ mDevicePolicyResources.getWorkTabAccessibilityLabel(),
+ TAB_TAG_WORK,
+ workAdapter)),
createEmptyStateProvider(workProfileUserHandle),
() -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(),
selectedProfile,
@@ -1104,23 +1056,20 @@ public class ResolverActivity extends FragmentActivity implements
/**
* 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.");
- }
+ Profile.Type selected = mResolverRequest.getSelectedProfile();
+ if (selected == null) {
+ return -1;
+ }
+ switch (selected) {
+ case PERSONAL: return PROFILE_PERSONAL;
+ case WORK: return PROFILE_WORK;
+ default: return -1;
}
- return selectedProfile;
}
- protected final @Profile int getCurrentProfile() {
+ protected final @ProfileType int getCurrentProfile() {
UserHandle launchUser = requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch;
UserHandle personalUser = requireAnnotatedUserHandles().personalProfileUserHandle;
return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK;
@@ -1144,24 +1093,6 @@ public class ResolverActivity extends FragmentActivity implements
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(
@@ -1219,28 +1150,8 @@ public class ResolverActivity extends FragmentActivity implements
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()
+ final ActionTitle title = mResolvingHome
? ActionTitle.HOME
: ActionTitle.forAction(intent.getAction());
@@ -1261,12 +1172,6 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- final void dismiss() {
- if (!isFinishing()) {
- finish();
- }
- }
-
@Override
protected final void onRestart() {
super.onRestart();
@@ -1297,17 +1202,6 @@ public class ResolverActivity extends FragmentActivity implements
}
}
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
@@ -1319,6 +1213,15 @@ public class ResolverActivity extends FragmentActivity implements
}
}
+ @Override
+ protected final void onStart() {
+ super.onStart();
+ this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
+ if (hasWorkProfile()) {
+ mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this);
+ }
+ }
+
private boolean hasManagedProfile() {
UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE);
if (userManager == null) {
@@ -1340,7 +1243,7 @@ public class ResolverActivity extends FragmentActivity implements
private boolean supportsManagedProfiles(ResolveInfo resolveInfo) {
try {
- ApplicationInfo appInfo = getPackageManager().getApplicationInfo(
+ ApplicationInfo appInfo = mPackageManager.getApplicationInfo(
resolveInfo.activityInfo.packageName, 0 /* default flags */);
return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP;
} catch (NameNotFoundException e) {
@@ -1358,7 +1261,8 @@ public class ResolverActivity extends FragmentActivity implements
// 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)) {
+ if (hasCloneProfile()
+ && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) {
mAlwaysButton.setEnabled(false);
return;
}
@@ -1384,16 +1288,14 @@ public class ResolverActivity extends FragmentActivity implements
if (ri != null) {
ActivityInfo activityInfo = ri.activityInfo;
- boolean hasRecordPermission =
- mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO,
+ boolean hasRecordPermission = mPackageManager
+ .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);
+ boolean hasAudioCapture = mResolverRequest.isAudioCaptureDevice();
enabled = !hasAudioCapture;
}
}
@@ -1406,10 +1308,8 @@ public class ResolverActivity extends FragmentActivity implements
if (isAutolaunching()) {
return;
}
- if (mIsIntentPicker) {
- ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter)
- .setUseLayoutWithDefault(useLayoutWithDefault());
- }
+ mMultiProfilePagerAdapter.setUseLayoutWithDefault(useLayoutWithDefault());
+
if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) {
mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter);
} else {
@@ -1458,39 +1358,6 @@ public class ResolverActivity extends FragmentActivity implements
}
}
- @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))
@@ -1511,7 +1378,7 @@ public class ResolverActivity extends FragmentActivity implements
// 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());
+ boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile());
if (shouldUseMiniResolver()) {
configureMiniResolverContent(targetDataLoader);
@@ -1541,11 +1408,6 @@ public class ResolverActivity extends FragmentActivity implements
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 =
@@ -1604,17 +1466,69 @@ public class ResolverActivity extends FragmentActivity implements
&& mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK);
}
+ @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 =
+ mIntentForwarding.forwardMessageFor(mResolverRequest.getIntent());
+ if (profileSwitchMessage != null) {
+ Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show();
+ }
+ try {
+ if (cti.startAsCaller(this, options, user.getIdentifier())) {
+ maybeLogCrossProfileTargetLaunch(cti, user);
+ }
+ } catch (RuntimeException e) {
+ Slog.wtf(TAG,
+ "Unable to launch as uid " + mActivityModel.getLaunchedFromUid()
+ + " package " + getLaunchedFromPackage() + ", while running in "
+ + ActivityThread.currentProcessName(), e);
+ }
+ }
+
+ /**
+ * 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 (hasWorkProfile()) {
+ setupProfileTabs();
+ }
+
+ return false;
+ }
+
/**
* 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.
+ * 2. This profile only has web browser matches.
+ * 3. The other profile has a single non-browser match.
*/
private boolean shouldUseMiniResolver() {
- if (!mIsIntentPicker) {
- return false;
- }
if (!isTwoPagePersonalAndWorkConfiguration()) {
return false;
}
@@ -1652,50 +1566,6 @@ public class ResolverActivity extends FragmentActivity implements
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) {
@@ -1761,7 +1631,7 @@ public class ResolverActivity extends FragmentActivity implements
}
String packageName = activeProfileTarget.getResolvedComponentName().getPackageName();
- if (!canAppInteractCrossProfiles(packageName)) {
+ if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) {
return false;
}
@@ -1776,131 +1646,66 @@ public class ResolverActivity extends FragmentActivity implements
return true;
}
+ private boolean isAutolaunching() {
+ return !mRegistered && isFinishing();
+ }
+
/**
- * 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>
- *
+ * @return {@code true} if a resolved target is autolaunched, otherwise {@code false}
*/
- 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) {
+ private boolean maybeAutolaunchActivity() {
+ if (!isTwoPagePersonalAndWorkConfiguration()) {
return false;
}
- int packageUid = applicationInfo.uid;
+ ResolverListAdapter activeListAdapter =
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getPersonalListAdapter()
+ : mMultiProfilePagerAdapter.getWorkListAdapter();
- 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;
+ ResolverListAdapter inactiveListAdapter =
+ (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)
+ ? mMultiProfilePagerAdapter.getWorkListAdapter()
+ : mMultiProfilePagerAdapter.getPersonalListAdapter();
+
+ if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) {
+ return false;
}
- if (PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES,
- PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED) {
- return true;
+
+ if ((activeListAdapter.getUnfilteredCount() != 1)
+ || (inactiveListAdapter.getUnfilteredCount() != 1)) {
+ return false;
}
- return false;
- }
- private boolean isAutolaunching() {
- return !mRegistered && isFinishing();
- }
+ TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false);
+ TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false);
+ if (!Objects.equals(
+ activeProfileTarget.getResolvedComponentName(),
+ inactiveProfileTarget.getResolvedComponentName())) {
+ return false;
+ }
- 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();
- });
+ if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) {
+ return false;
+ }
- viewPager.setVisibility(View.VISIBLE);
- tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage());
- mMultiProfilePagerAdapter.setOnProfileSelectedListener(
- new MultiProfilePagerAdapter.OnProfileSelectedListener() {
- @Override
- public void onProfileSelected(int index) {
- tabHost.setCurrentTab(index);
- resetButtonBar();
- resetCheckedItem();
- }
+ String packageName = activeProfileTarget.getResolvedComponentName().getPackageName();
+ if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) {
+ return false;
+ }
- @Override
- public void onProfilePageStateChanged(int state) {
- onHorizontalSwipeStateChanged(state);
- }
- });
- mOnSwitchOnWorkSelectedListener = () -> {
- final View workTab = tabHost.getTabWidget().getChildAt(1);
- workTab.setFocusable(true);
- workTab.setFocusableInTouchMode(true);
- workTab.requestFocus();
- };
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET)
+ .setBoolean(activeListAdapter.getUserHandle()
+ .equals(requireAnnotatedUserHandles().personalProfileUserHandle))
+ .setStrings(getMetricsCategory())
+ .write();
+ safelyStartActivity(activeProfileTarget);
+ finish();
+ return true;
}
private void maybeHideDivider() {
- if (!mIsIntentPicker) {
- return;
- }
final View divider = findViewById(com.android.internal.R.id.divider);
if (divider == null) {
return;
@@ -1909,29 +1714,11 @@ public class ResolverActivity extends FragmentActivity implements
}
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)) {
@@ -1957,10 +1744,7 @@ public class ResolverActivity extends FragmentActivity implements
private void setupAdapterListView(ListView listView, ItemClickListener listener) {
listView.setOnItemClickListener(listener);
listView.setOnItemLongClickListener(listener);
-
- if (mLogic.getSupportsAlwaysUseOption()) {
- listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
- }
+ listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
}
/**
@@ -1971,7 +1755,7 @@ public class ResolverActivity extends FragmentActivity implements
&& !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) {
return;
}
- if (!shouldShowTabs()
+ if (!hasWorkProfile()
&& listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) {
final TextView titleView = findViewById(com.android.internal.R.id.title);
if (titleView != null) {
@@ -1979,10 +1763,9 @@ public class ResolverActivity extends FragmentActivity implements
}
}
-
- CharSequence title = mLogic.getTitle() != null
- ? mLogic.getTitle()
- : getTitleForAction(mLogic.getTargetIntent(), mLogic.getDefaultTitleResId());
+ CharSequence title = mResolverRequest.getTitle() != null
+ ? mResolverRequest.getTitle()
+ : getTitleForAction(mResolverRequest.getIntent(), 0);
if (!TextUtils.isEmpty(title)) {
final TextView titleView = findViewById(com.android.internal.R.id.title);
@@ -2027,19 +1810,9 @@ public class ResolverActivity extends FragmentActivity implements
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;
+ return mMultiProfilePagerAdapter.getListAdapterForUserHandle(
+ requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch
+ ).hasFilteredItem();
}
final class ItemClickListener implements AdapterView.OnItemClickListener,
@@ -2096,11 +1869,37 @@ public class ResolverActivity extends FragmentActivity implements
}
- /** 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;
+ private void setupProfileTabs() {
+ maybeHideDivider();
+
+ TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost);
+ ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager);
+
+ mMultiProfilePagerAdapter.setupProfileTabs(
+ getLayoutInflater(),
+ tabHost,
+ viewPager,
+ R.layout.resolver_profile_tab_button,
+ com.android.internal.R.id.profile_pager,
+ () -> onProfileTabSelected(viewPager.getCurrentItem()),
+ new OnProfileSelectedListener() {
+ @Override
+ public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {
+ resetButtonBar();
+ resetCheckedItem();
+ }
+
+ @Override
+ public void onProfilePageStateChanged(int state) {}
+ });
+ mOnSwitchOnWorkSelectedListener = () -> {
+ final View workTab =
+ tabHost.getTabWidget().getChildAt(
+ mMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK));
+ workTab.setFocusable(true);
+ workTab.setFocusableInTouchMode(true);
+ workTab.requestFocus();
+ };
}
static final class PickTargetOptionRequest extends PickOptionRequest {
@@ -2173,7 +1972,7 @@ public class ResolverActivity extends FragmentActivity implements
private CharSequence getOrLoadDisplayLabel(TargetInfo info) {
if (info.isDisplayResolveInfo()) {
- mLogic.getTargetDataLoader().getOrLoadLabel((DisplayResolveInfo) info);
+ mTargetDataLoader.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
index 0e2b25ec..7eb63ab3 100644
--- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt
+++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt
@@ -1,81 +1,18 @@
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,
+ activity: ComponentActivity,
onWorkProfileStatusUpdated: () -> Unit,
) :
ActivityLogic,
CommonActivityLogic by CommonActivityLogicImpl(
tag,
- activityProvider,
+ activity,
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/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt
new file mode 100644
index 00000000..15c5018a
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 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.annotation
+
+/**
+ * Apply to code which exists specifically to easy integration with existing Java and Java APIs.
+ *
+ * The goal is to prevent usage from Kotlin when a more idiomatic alternative is available.
+ */
+@RequiresOptIn("This is a a property, function or class specifically supporting Java " +
+ "interoperability. Usage from Kotlin should be limited to interactions with Java.")
+annotation class JavaInterop
diff --git a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt
index 7debdf07..5719ff08 100644
--- a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt
+++ b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt
@@ -16,6 +16,8 @@
package com.android.intentresolver.v2.data.repository
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.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
@@ -28,41 +30,71 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class DevicePolicyResources @Inject constructor(
+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)
- })
+ 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)
- })
+ 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)
- })
+ 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)
- })
+ requireNotNull(
+ policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) {
+ resources.getString(R.string.resolver_work_tab_accessibility)
+ }
+ )
+ }
+
+ val forwardToPersonalMessage: String? =
+ devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) {
+ resources.getString(R.string.forward_intent_to_owner)
+ }
+
+ val forwardToWorkMessage by lazy {
+ requireNotNull(
+ policyResources.getString(FORWARD_INTENT_TO_WORK) {
+ resources.getString(R.string.forward_intent_to_work)
+ }
+ )
}
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))
+ 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
index fc82efee..a0b2d1ef 100644
--- a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt
+++ b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt
@@ -1,8 +1,8 @@
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
+import com.android.intentresolver.v2.shared.model.User
+import com.android.intentresolver.v2.shared.model.User.Role
/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */
fun UserInfo.getSupportedUserRole(): 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
index dc809b46..b57609e5 100644
--- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt
+++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt
@@ -20,8 +20,8 @@ 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 com.android.intentresolver.v2.shared.model.User
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
@@ -39,17 +39,17 @@ import kotlinx.coroutines.withContext
interface UserRepository {
/**
- * A [Flow] user profile groups. Each map contains the context user along with all members of
+ * A [Flow] user profile groups. Each list 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>>
+ val users: Flow<List<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>
+ val availability: Flow<Map<User, Boolean>>
/**
* Request that availability be updated to the requested state. This currently includes toggling
@@ -70,7 +70,7 @@ private const val TAG = "UserRepository"
private data class UserWithState(val user: User, val available: Boolean)
-private typealias UserStateMap = Map<UserHandle, UserWithState>
+private typealias UserStates = List<UserWithState>
/** Tracks and publishes state for the parent user and associated profiles. */
class UserRepositoryImpl
@@ -110,15 +110,16 @@ constructor(
override val cause: Throwable? = null
) : RuntimeException("$message: event=$event", cause)
- private val usersWithState: Flow<UserStateMap> =
+ private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher)
+ private val usersWithState: Flow<UserStates> =
userEvents
.onStart { emit(UserEvent(INITIALIZE, profileParent)) }
- .onEach { Log.i("UserDataSource", "userEvent: $it") }
- .runningFold<UserEvent, UserStateMap>(emptyMap()) { users, event ->
+ .onEach { Log.i(TAG, "userEvent: $it") }
+ .runningFold<UserEvent, UserStates>(emptyList()) { users, event ->
try {
// Handle an action by performing some operation, then returning a new map
when (event.action) {
- INITIALIZE -> createNewUserStateMap(profileParent)
+ INITIALIZE -> createNewUserStates(profileParent)
ACTION_PROFILE_ADDED -> handleProfileAdded(event, users)
ACTION_PROFILE_REMOVED -> handleProfileRemoved(event, users)
ACTION_MANAGED_PROFILE_UNAVAILABLE,
@@ -133,77 +134,67 @@ constructor(
} catch (e: UserStateException) {
Log.e(TAG, "An error occurred handling an event: ${e.event}", e)
Log.e(TAG, "Attempting to recover...")
- createNewUserStateMap(profileParent)
+ createNewUserStates(profileParent)
}
}
- .onEach { Log.i("UserDataSource", "userStateMap: $it") }
- .stateIn(scope, SharingStarted.Eagerly, emptyMap())
+ .distinctUntilChanged()
+ .onEach { Log.i(TAG, "userStateList: $it") }
+ .stateIn(sharingScope, SharingStarted.Eagerly, emptyList())
.filterNot { it.isEmpty() }
- override val users: Flow<Map<UserHandle, User>> =
- usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged()
+ override val users: Flow<List<User>> =
+ usersWithState.map { userStateMap -> userStateMap.map { it.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 val availability: Flow<Map<User, Boolean>> =
+ usersWithState
+ .map { list -> list.associate { it.user to it.available } }
+ .distinctUntilChanged()
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)
+ userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle)
}
}
- private fun handleAvailability(event: UserEvent, current: UserStateMap): UserStateMap {
+ private fun List<UserWithState>.update(handle: UserHandle, user: UserWithState) =
+ filter { it.user.id != handle.identifier } + user
+
+ private fun handleAvailability(event: UserEvent, current: UserStates): UserStates {
val userEntry =
- current[event.user]
+ current.firstOrNull { it.user.id == event.user.identifier }
?: throw UserStateException("User was not present in the map", event)
- return current + (event.user to userEntry.copy(available = !event.quietMode))
+ return current.update(event.user, userEntry.copy(available = !event.quietMode))
}
- private fun handleProfileRemoved(event: UserEvent, current: UserStateMap): UserStateMap {
- if (!current.containsKey(event.user)) {
+ private fun handleProfileRemoved(event: UserEvent, current: UserStates): UserStates {
+ if (!current.any { it.user.id == event.user.identifier }) {
throw UserStateException("User was not present in the map", event)
}
- return current.filterKeys { it != event.user }
+ return current.filter { it.user.id != event.user.identifier }
}
- private suspend fun handleProfileAdded(event: UserEvent, current: UserStateMap): UserStateMap {
+ private suspend fun handleProfileAdded(event: UserEvent, current: UserStates): UserStates {
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))
+ return current + UserWithState(user, !event.quietMode)
}
- private suspend fun createNewUserStateMap(user: UserHandle): UserStateMap {
+ private suspend fun createNewUserStates(user: UserHandle): UserStates {
val profiles = readProfileGroup(user)
- return profiles
- .mapNotNull { userInfo ->
- userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) }
- }
- .associateBy { it.user.handle }
+ return profiles.mapNotNull { userInfo ->
+ userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) }
+ }
}
- private suspend fun readProfileGroup(handle: UserHandle): List<UserInfo> {
+ private suspend fun readProfileGroup(member: UserHandle): List<UserInfo> {
return withContext(backgroundDispatcher) {
- @Suppress("DEPRECATION") userManager.getEnabledProfiles(handle.identifier)
+ @Suppress("DEPRECATION") userManager.getEnabledProfiles(member.identifier)
}
.toList()
}
diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt
index 94f985e7..a84342f4 100644
--- a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt
+++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt
@@ -25,8 +25,11 @@ interface UserRepositoryModule {
@Provides
@Singleton
@ProfileParent
- fun profileParent(@ApplicationUser user: UserHandle, userManager: UserManager): UserHandle {
- return userManager.getProfileParent(user) ?: user
+ fun profileParent(
+ @ApplicationContext context: Context,
+ userManager: UserManager
+ ): UserHandle {
+ return userManager.getProfileParent(context.user) ?: context.user
}
}
diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt
index 7ee78d91..3553744a 100644
--- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt
+++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt
@@ -2,7 +2,7 @@ package com.android.intentresolver.v2.data.repository
import android.content.Context
import androidx.core.content.getSystemService
-import com.android.intentresolver.v2.data.model.User
+import com.android.intentresolver.v2.shared.model.User
/**
* Provides cached instances of a [system service][Context.getSystemService] created with
diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt
new file mode 100644
index 00000000..72b604c2
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2024 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.domain.interactor
+
+import android.os.UserHandle
+import com.android.intentresolver.inject.ApplicationUser
+import com.android.intentresolver.v2.data.repository.UserRepository
+import com.android.intentresolver.v2.shared.model.Profile
+import com.android.intentresolver.v2.shared.model.Profile.Type
+import com.android.intentresolver.v2.shared.model.User
+import com.android.intentresolver.v2.shared.model.User.Role
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+
+/** The high level User interface. */
+class UserInteractor
+@Inject
+constructor(
+ private val userRepository: UserRepository,
+ /** The specific [User] of the application which started this one. */
+ @ApplicationUser val launchedAs: UserHandle,
+) {
+ /** The profile group associated with the launching app user. */
+ val profiles: Flow<List<Profile>> =
+ userRepository.users.map { users ->
+ users.mapNotNull { user ->
+ when (user.role) {
+ // PERSONAL includes CLONE
+ Role.PERSONAL -> {
+ Profile(Type.PERSONAL, user, users.firstOrNull { it.role == Role.CLONE })
+ }
+ Role.CLONE -> {
+ /* ignore, included above */
+ null
+ }
+ // others map 1:1
+ else -> Profile(profileFromRole(user.role), user)
+ }
+ }
+ }
+
+ /** The [Profile] of the application which started this one. */
+ val launchedAsProfile: Flow<Profile> =
+ profiles.map { profiles ->
+ // The launching user profile is the one with a primary id or clone id
+ // matching the application user id. By definition there must always be exactly
+ // one matching profile for the current user.
+ profiles.single {
+ it.primary.id == launchedAs.identifier || it.clone?.id == launchedAs.identifier
+ }
+ }
+ /**
+ * Provides a flow to report on the availability of profile. An unavailable profile may be
+ * hidden or appear disabled within the app.
+ */
+ val availability: Flow<Map<Profile, Boolean>> =
+ combine(profiles, userRepository.availability) { profiles, availability ->
+ profiles.associateWith {
+ availability.getOrDefault(it.primary, false)
+ }
+ }
+
+ /**
+ * Request the profile state be updated. In the case of enabling, the operation could take
+ * significant time and/or require user input.
+ */
+ suspend fun updateState(profile: Profile, available: Boolean) {
+ userRepository.requestState(profile.primary, available)
+ }
+
+ private fun profileFromRole(role: Role): Type =
+ when (role) {
+ Role.PERSONAL -> Type.PERSONAL
+ Role.CLONE -> Type.PERSONAL /* CLONE maps to PERSONAL */
+ Role.PRIVATE -> Type.PRIVATE
+ Role.WORK -> Type.WORK
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java
index e9d1bb34..dfc46697 100644
--- a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java
+++ b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java
@@ -29,11 +29,11 @@ import android.stats.devicepolicy.nano.DevicePolicyEnums;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import com.android.intentresolver.R;
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;
diff --git a/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java
new file mode 100644
index 00000000..eaed35a7
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.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;
+
+/**
+ * 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 ResolverWorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
+
+ private final UserHandle mWorkProfileUserHandle;
+ private final WorkProfileAvailabilityManager mWorkProfileAvailability;
+ private final String mMetricsCategory;
+ private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
+ private final Context mContext;
+
+ public ResolverWorkProfilePausedEmptyStateProvider(@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/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt
new file mode 100644
index 00000000..6c36e6aa
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024 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.ext
+
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.core.os.bundleOf
+import androidx.lifecycle.DEFAULT_ARGS_KEY
+import androidx.lifecycle.viewmodel.CreationExtras
+import androidx.lifecycle.viewmodel.MutableCreationExtras
+
+/**
+ * Returns a new instance with additional [values] added to the existing default args Bundle (if
+ * present), otherwise adds a new entry with a copy of this bundle.
+ */
+fun CreationExtras.addDefaultArgs(vararg values: Pair<String, Parcelable>): CreationExtras {
+ val defaultArgs: Bundle = get(DEFAULT_ARGS_KEY) ?: Bundle()
+ defaultArgs.putAll(bundleOf(*values))
+ return MutableCreationExtras(this).apply { set(DEFAULT_ARGS_KEY, defaultArgs) }
+}
diff --git a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt
new file mode 100644
index 00000000..8c2d7277
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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.ext
+
+import android.content.Intent
+import java.util.function.Predicate
+
+/** Applies an operation on this Intent if matches the given filter. */
+inline fun Intent.ifMatch(
+ predicate: Predicate<Intent>,
+ crossinline block: Intent.() -> Unit
+): Intent {
+ if (predicate.test(this)) {
+ apply(block)
+ }
+ return this
+}
+
+/** True if the Intent has one of the specified actions. */
+fun Intent.hasAction(vararg actions: String): Boolean = action in actions
+
+/** True if the Intent has a specific component target */
+fun Intent.hasComponent(): Boolean = (component != null)
+
+/** True if the Intent has a single matching category. */
+fun Intent.hasSingleCategory(category: String) = categories.singleOrNull() == category
+
+/** True if the Intent is a SEND or SEND_MULTIPLE action. */
+fun Intent.hasSendAction() = hasAction(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
+
+/** True if the Intent resolves to the special Home (Launcher) component */
+fun Intent.isHomeIntent() = hasAction(Intent.ACTION_MAIN) && hasSingleCategory(Intent.CATEGORY_HOME)
diff --git a/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt b/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt
new file mode 100644
index 00000000..b0ec97f4
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024 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.ext
+
+import android.os.Parcel
+
+inline fun <reified T> Parcel.requireParcelable(): T {
+ return requireNotNull(readParcelable<T>()) { "A non-value required from this parcel was null!" }
+}
+
+inline fun <reified T> Parcel.readParcelable(): T? {
+ return readParcelable(T::class.java.classLoader, T::class.java)
+}
diff --git a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt
new file mode 100644
index 00000000..9ca9d871
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.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.platform
+
+import android.content.pm.PackageManager
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class AppPredictionAvailable
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppPredictionModule {
+
+ /**
+ * Eventually replaced with: Optional<AppPredictionRepository>, etc.
+ */
+ @Provides
+ @Singleton
+ @AppPredictionAvailable
+ fun isAppPredictionAvailable(packageManager: PackageManager): Boolean {
+ return packageManager.appPredictionServicePackageName != null
+ }
+} \ No newline at end of file
diff --git a/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java b/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java
new file mode 100644
index 00000000..c5b35273
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 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.profiles;
+
+/**
+ * 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);
+}
diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java
index de0a9426..0ee9d141 100644
--- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 The Android Open Source Project
+ * Copyright (C) 2024 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,7 +14,7 @@
* limitations under the License.
*/
-package com.android.intentresolver.v2;
+package com.android.intentresolver.v2.profiles;
import android.content.Context;
import android.os.UserHandle;
@@ -32,7 +32,6 @@ 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;
@@ -42,7 +41,6 @@ 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;
@@ -52,9 +50,10 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter<
public ChooserMultiProfilePagerAdapter(
Context context,
- ChooserGridAdapter adapter,
+ ImmutableList<TabConfig<ChooserGridAdapter>> tabs,
EmptyStateProvider emptyStateProvider,
Supplier<Boolean> workProfileQuietModeChecker,
+ @ProfileType int defaultProfile,
UserHandle workProfileUserHandle,
UserHandle cloneProfileUserHandle,
int maxTargetsPerRow,
@@ -62,31 +61,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter<
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),
+ tabs,
emptyStateProvider,
workProfileQuietModeChecker,
defaultProfile,
@@ -99,10 +74,10 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter<
private ChooserMultiProfilePagerAdapter(
Context context,
ChooserProfileAdapterBinder adapterBinder,
- ImmutableList<ChooserGridAdapter> gridAdapters,
+ ImmutableList<TabConfig<ChooserGridAdapter>> tabs,
EmptyStateProvider emptyStateProvider,
Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
+ @ProfileType int defaultProfile,
UserHandle workProfileUserHandle,
UserHandle cloneProfileUserHandle,
BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier,
@@ -110,7 +85,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter<
super(
gridAdapter -> gridAdapter.getListAdapter(),
adapterBinder,
- gridAdapters,
+ tabs,
emptyStateProvider,
workProfileQuietModeChecker,
defaultProfile,
@@ -137,7 +112,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter<
*/
public void setIsCollapsed(boolean isCollapsed) {
for (int i = 0, size = getItemCount(); i < size; i++) {
- getAdapterForIndex(i).setAzLabelVisibility(!isCollapsed);
+ getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed);
}
}
@@ -172,7 +147,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter<
/** 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);
+ getPageAdapterForIndex(i).setFooterHeight(height);
}
}
diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java
index 2d9be816..43785db3 100644
--- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 The Android Open Source Project
+ * Copyright (C) 2024 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.
@@ -13,14 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.android.intentresolver.v2;
+package com.android.intentresolver.v2.profiles;
import android.annotation.IntDef;
import android.annotation.Nullable;
import android.os.Trace;
import android.os.UserHandle;
+import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TabHost;
+import android.widget.TextView;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
@@ -28,33 +32,21 @@ 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.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
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
@@ -69,24 +61,11 @@ public class MultiProfilePagerAdapter<
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 {}
+ public @interface ProfileType {}
private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor;
private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder;
@@ -99,22 +78,21 @@ public class MultiProfilePagerAdapter<
private final UserHandle mCloneProfileUserHandle;
private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet.
- private Set<Integer> mLoadedPages;
+ private final Set<Integer> mLoadedPages;
private int mCurrentPage;
private OnProfileSelectedListener mOnProfileSelectedListener;
protected MultiProfilePagerAdapter(
Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor,
AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder,
- ImmutableList<SinglePageAdapterT> adapters,
+ ImmutableList<TabConfig<SinglePageAdapterT>> tabs,
EmptyStateProvider emptyStateProvider,
Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
+ @ProfileType int defaultProfile,
UserHandle workProfileUserHandle,
UserHandle cloneProfileUserHandle,
Supplier<ViewGroup> pageViewInflater,
Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) {
- mCurrentPage = defaultProfile;
mLoadedPages = new HashSet<>();
mWorkProfileUserHandle = workProfileUserHandle;
mCloneProfileUserHandle = cloneProfileUserHandle;
@@ -127,21 +105,190 @@ public class MultiProfilePagerAdapter<
ImmutableList.Builder<ProfileDescriptor<PageViewT, SinglePageAdapterT>> items =
new ImmutableList.Builder<>();
- for (SinglePageAdapterT adapter : adapters) {
- items.add(createProfileDescriptor(adapter, containerBottomPaddingOverrideSupplier));
+ for (TabConfig<SinglePageAdapterT> tab : tabs) {
+ // TODO: consider representing tabConfig in a different data structure that can ensure
+ // uniqueness of their profile assignments (while still respecting the client's
+ // requested tab order).
+ items.add(
+ createProfileDescriptor(
+ tab.mProfile,
+ tab.mTabLabel,
+ tab.mTabAccessibilityLabel,
+ tab.mTabTag,
+ tab.mPageAdapter,
+ containerBottomPaddingOverrideSupplier));
}
mItems = items.build();
+
+ mCurrentPage =
+ hasPageForProfile(defaultProfile) ? getPageNumberForProfile(defaultProfile) : 0;
}
private ProfileDescriptor<PageViewT, SinglePageAdapterT> createProfileDescriptor(
+ @ProfileType int profile,
+ String tabLabel,
+ String tabAccessibilityLabel,
+ String tabTag,
SinglePageAdapterT adapter,
Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) {
return new ProfileDescriptor<>(
- mPageViewInflater.get(), adapter, containerBottomPaddingOverrideSupplier);
+ profile,
+ tabLabel,
+ tabAccessibilityLabel,
+ tabTag,
+ mPageViewInflater.get(),
+ adapter,
+ containerBottomPaddingOverrideSupplier);
+ }
+
+ private boolean hasPageForIndex(int pageIndex) {
+ return (pageIndex >= 0) && (pageIndex < getCount());
+ }
+
+ public final boolean hasPageForProfile(@ProfileType int profile) {
+ return hasPageForIndex(getPageNumberForProfile(profile));
+ }
+
+ private @ProfileType int getProfileForPageNumber(int position) {
+ if (hasPageForIndex(position)) {
+ return mItems.get(position).mProfile;
+ }
+ return -1;
+ }
+
+ public int getPageNumberForProfile(@ProfileType int profile) {
+ for (int i = 0; i < mItems.size(); ++i) {
+ if (profile == mItems.get(i).mProfile) {
+ return i;
+ }
+ }
+ return -1;
}
- public void setOnProfileSelectedListener(OnProfileSelectedListener listener) {
- mOnProfileSelectedListener = listener;
+ private ListAdapterT getListAdapterForPageNumber(int pageNumber) {
+ SinglePageAdapterT pageAdapter = getPageAdapterForIndex(pageNumber);
+ if (pageAdapter == null) {
+ return null;
+ }
+ return mListAdapterExtractor.apply(pageAdapter);
+ }
+
+ private @ProfileType int getProfileForUserHandle(UserHandle userHandle) {
+ if (userHandle.equals(getCloneUserHandle())) {
+ // TODO: can we push this special case elsewhere -- e.g., when we check against each
+ // list adapter's user handle in the loop below, could we instead ask the list adapter
+ // whether it "represents" the queried user handle, and have the personal list adapter
+ // return true because it knows it's also associated with the clone profile? Or if we
+ // don't want to make modifications to the list adapter, maybe we could at least specify
+ // it in our per-page configuration data that we use to build our tabs/pages, and then
+ // maintain the relevant bookkeeping in our own ProfileDescriptor?
+ return PROFILE_PERSONAL;
+ }
+ for (int i = 0; i < mItems.size(); ++i) {
+ ListAdapterT listAdapter = getListAdapterForPageNumber(i);
+ if (listAdapter.getUserHandle().equals(userHandle)) {
+ return mItems.get(i).mProfile;
+ }
+ }
+ return -1;
+ }
+
+ private int getPageNumberForUserHandle(UserHandle userHandle) {
+ return getPageNumberForProfile(getProfileForUserHandle(userHandle));
+ }
+
+ /**
+ * 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) {
+ return getListAdapterForPageNumber(getPageNumberForUserHandle(userHandle));
+ }
+
+ @Nullable
+ private ProfileDescriptor<PageViewT, SinglePageAdapterT> getDescriptorForUserHandle(
+ UserHandle userHandle) {
+ return getItem(getPageNumberForUserHandle(userHandle));
+ }
+
+ private int getPageNumberForTabTag(String tag) {
+ for (int i = 0; i < mItems.size(); ++i) {
+ if (Objects.equals(mItems.get(i).mTabTag, tag)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void updateActiveTabStyle(TabHost tabHost) {
+ int currentTab = tabHost.getCurrentTab();
+
+ for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) {
+ // TODO: can we avoid this downcast by pushing our knowledge of the intended view type
+ // somewhere else?
+ TextView tabText = (TextView) tabHost.getTabWidget().getChildAt(pageNumber);
+ tabText.setSelected(currentTab == pageNumber);
+ }
+ }
+
+ public void setupProfileTabs(
+ LayoutInflater layoutInflater,
+ TabHost tabHost,
+ ViewPager viewPager,
+ int tabButtonLayoutResId,
+ int tabPageContentViewId,
+ Runnable onTabChangeListener,
+ OnProfileSelectedListener clientOnProfileSelectedListener) {
+ tabHost.setup();
+ viewPager.setSaveEnabled(false);
+
+ for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) {
+ ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = mItems.get(pageNumber);
+ Button profileButton = (Button) layoutInflater.inflate(
+ tabButtonLayoutResId, tabHost.getTabWidget(), false);
+ profileButton.setText(descriptor.mTabLabel);
+ profileButton.setContentDescription(descriptor.mTabAccessibilityLabel);
+
+ TabHost.TabSpec profileTabSpec = tabHost.newTabSpec(descriptor.mTabTag)
+ .setContent(tabPageContentViewId)
+ .setIndicator(profileButton);
+ tabHost.addTab(profileTabSpec);
+ }
+
+ tabHost.getTabWidget().setVisibility(View.VISIBLE);
+
+ updateActiveTabStyle(tabHost);
+
+ tabHost.setOnTabChangedListener(tabTag -> {
+ updateActiveTabStyle(tabHost);
+
+ int pageNumber = getPageNumberForTabTag(tabTag);
+ if (pageNumber >= 0) {
+ viewPager.setCurrentItem(pageNumber);
+ }
+ onTabChangeListener.run();
+ });
+
+ viewPager.setVisibility(View.VISIBLE);
+ tabHost.setCurrentTab(getCurrentPage());
+ mOnProfileSelectedListener =
+ new OnProfileSelectedListener() {
+ @Override
+ public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {
+ tabHost.setCurrentTab(pageNumber);
+ clientOnProfileSelectedListener.onProfilePageSelected(
+ profileId, pageNumber);
+ }
+
+ @Override
+ public void onProfilePageStateChanged(int state) {
+ clientOnProfileSelectedListener.onProfilePageStateChanged(state);
+ }
+ };
}
/**
@@ -159,7 +306,8 @@ public class MultiProfilePagerAdapter<
mLoadedPages.add(position);
}
if (mOnProfileSelectedListener != null) {
- mOnProfileSelectedListener.onProfileSelected(position);
+ mOnProfileSelectedListener.onProfilePageSelected(
+ getProfileForPageNumber(position), position);
}
}
@@ -176,10 +324,7 @@ public class MultiProfilePagerAdapter<
}
public void clearInactiveProfileCache() {
- if (mLoadedPages.size() == 1) {
- return;
- }
- mLoadedPages.remove(1 - mCurrentPage);
+ forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber));
}
@Override
@@ -204,12 +349,8 @@ public class MultiProfilePagerAdapter<
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();
+ public final @ProfileType int getActiveProfile() {
+ return getProfileForPageNumber(getCurrentPage());
}
@VisibleForTesting
@@ -241,7 +382,11 @@ public class MultiProfilePagerAdapter<
* <code>1</code> would return the work profile {@link ProfileDescriptor}.</li>
* </ul>
*/
+ @Nullable
private ProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) {
+ if (!hasPageForIndex(pageIndex)) {
+ return null;
+ }
return mItems.get(pageIndex);
}
@@ -263,7 +408,7 @@ public class MultiProfilePagerAdapter<
}
public final PageViewT getListViewForIndex(int index) {
- return getItem(index).mView;
+ return getItem(index).getView();
}
/**
@@ -273,8 +418,11 @@ public class MultiProfilePagerAdapter<
* depending on the adapter type.
*/
@VisibleForTesting
- public final SinglePageAdapterT getAdapterForIndex(int index) {
- return getItem(index).mAdapter;
+ public final SinglePageAdapterT getPageAdapterForIndex(int index) {
+ if (!hasPageForIndex(index)) {
+ return null;
+ }
+ return getItem(index).getAdapter();
}
/**
@@ -282,26 +430,7 @@ public class MultiProfilePagerAdapter<
* 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;
+ mAdapterBinder.bind(getListViewForIndex(pageIndex), getPageAdapterForIndex(pageIndex));
}
/**
@@ -309,70 +438,35 @@ public class MultiProfilePagerAdapter<
* 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()));
+ return getListAdapterForPageNumber(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);
+ return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL));
}
@Nullable
public final ListAdapterT getWorkListAdapter() {
- if (!hasAdapterForIndex(PROFILE_WORK)) {
+ if (!hasPageForProfile(PROFILE_WORK)) {
return null;
}
- return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK));
+ return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_WORK));
}
public final SinglePageAdapterT getCurrentRootAdapter() {
- return getAdapterForIndex(getCurrentPage());
+ return getPageAdapterForIndex(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));
+ ListAdapterT listAdapter = getListAdapterForPageNumber(i);
if (listAdapter.getCount() > 0) {
return true;
}
@@ -381,13 +475,10 @@ public class MultiProfilePagerAdapter<
}
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.
+ // TODO: 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();
- }
+ forEachInactivePage(page -> getListAdapterForPageNumber(page).handlePackagesChanged());
}
/**
@@ -445,9 +536,10 @@ public class MultiProfilePagerAdapter<
// autolaunch conditions).
boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded();
if (includePartialRebuildOfInactiveTabs) {
- boolean rebuildInactiveCompleted =
- rebuildInactiveTab(false) || getInactiveListAdapter().isTabLoaded();
- rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted;
+ // Per legacy logic, avoid short-circuiting (TODO: why? possibly so that we *start*
+ // loading the inactive tabs even if we're still waiting on the active tab to finish?).
+ boolean completedRebuildingInactiveTabs = rebuildInactiveTabs(false);
+ rebuildCompleted = rebuildCompleted && completedRebuildingInactiveTabs;
}
return rebuildCompleted;
}
@@ -464,28 +556,43 @@ public class MultiProfilePagerAdapter<
}
/**
- * Rebuilds the tab that is not currently visible to the user, if such one exists.
- * <p>Returns {@code true} if rebuild has completed.
+ * Rebuilds any tabs that are not currently visible to the user.
+ * <p>Returns {@code true} if rebuild has completed in all inactive tabs.
*/
- private boolean rebuildInactiveTab(boolean doPostProcessing) {
+ private boolean rebuildInactiveTabs(boolean doPostProcessing) {
Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab");
- if (getItemCount() == 1) {
- Trace.endSection();
- return false;
- }
- boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing);
+ AtomicBoolean allRebuildsComplete = new AtomicBoolean(true);
+ forEachInactivePage(pageNumber -> {
+ // Evaluate the rebuild for every inactive page, even if we've already seen some adapter
+ // return an "incomplete" status (i.e., even if `allRebuildsComplete` is already false)
+ // and so we already know we'll end up returning false for the batch.
+ // TODO: any particular reason the per-page legacy logic was set up in this order, or
+ // could we possibly short-circuit the rebuild if the tab is already "loaded"?
+ ListAdapterT inactiveAdapter = getListAdapterForPageNumber(pageNumber);
+ boolean rebuildInactivePageCompleted =
+ rebuildTab(inactiveAdapter, doPostProcessing) || inactiveAdapter.isTabLoaded();
+ if (!rebuildInactivePageCompleted) {
+ allRebuildsComplete.set(false);
+ }
+ });
Trace.endSection();
- return result;
+ return allRebuildsComplete.get();
}
- private int userHandleToPageIndex(UserHandle userHandle) {
- if (userHandle.equals(getPersonalListAdapter().getUserHandle())) {
- return PROFILE_PERSONAL;
- } else {
- return PROFILE_WORK;
+ protected void forEachPage(Consumer<Integer> pageNumberHandler) {
+ for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) {
+ pageNumberHandler.accept(pageNumber);
}
}
+ protected void forEachInactivePage(Consumer<Integer> inactivePageNumberHandler) {
+ forEachPage(pageNumber -> {
+ if (pageNumber != getCurrentPage()) {
+ inactivePageNumberHandler.accept(pageNumber);
+ }
+ });
+ }
+
protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) {
if (shouldSkipRebuild(activeListAdapter)) {
activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true);
@@ -499,10 +606,6 @@ public class MultiProfilePagerAdapter<
return emptyState != null && emptyState.shouldSkipDataRebuild();
}
- private boolean hasAdapterForIndex(int pageIndex) {
- return (pageIndex < getCount());
- }
-
/**
* The empty state screens are shown according to their priority:
* <ol>
@@ -531,8 +634,8 @@ public class MultiProfilePagerAdapter<
if (emptyState.getButtonClickListener() != null) {
clickListener = v -> emptyState.getButtonClickListener().onClick(() -> {
- ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem(
- userHandleToPageIndex(listAdapter.getUserHandle()));
+ ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor =
+ getDescriptorForUserHandle(listAdapter.getUserHandle());
descriptor.mEmptyStateUi.showSpinner();
});
}
@@ -540,24 +643,12 @@ public class MultiProfilePagerAdapter<
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()));
+ ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor =
+ getDescriptorForUserHandle(activeListAdapter.getUserHandle());
descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick);
activeListAdapter.markTabLoaded();
}
@@ -571,8 +662,8 @@ public class MultiProfilePagerAdapter<
}
public void showListView(ListAdapterT activeListAdapter) {
- ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem(
- userHandleToPageIndex(activeListAdapter.getUserHandle()));
+ ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor =
+ getDescriptorForUserHandle(activeListAdapter.getUserHandle());
descriptor.mEmptyStateUi.hide();
}
@@ -581,11 +672,14 @@ public class MultiProfilePagerAdapter<
* 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());
+ AtomicBoolean anyEmpty = new AtomicBoolean(false);
+ // TODO: The "inactive" condition is legacy logic. Could we simplify and ask "any"?
+ forEachInactivePage(pageNumber -> {
+ if (shouldShowEmptyStateScreen(getListAdapterForPageNumber(pageNumber))) {
+ anyEmpty.set(true);
+ }
+ });
+ return anyEmpty.get();
}
public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) {
@@ -595,72 +689,4 @@ public class MultiProfilePagerAdapter<
&& 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/profiles/OnProfileSelectedListener.java b/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java
new file mode 100644
index 00000000..7bdbec4c
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2024 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.profiles;
+
+import androidx.viewpager.widget.ViewPager;
+
+/** Listener interface for changes between the per-profile UI tabs. */
+public interface OnProfileSelectedListener {
+ /**
+ * Callback for when the user changes the active tab.
+ * <p>This callback is only called when the intent resolver or share sheet shows
+ * more than one profile.
+ *
+ * @param profileId the ID of the newly-selected profile, e.g. {@link #PROFILE_PERSONAL}
+ * if the personal profile tab was selected or {@link #PROFILE_WORK} if the
+ * work profile tab
+ * was selected.
+ */
+ void onProfilePageSelected(@MultiProfilePagerAdapter.ProfileType int profileId, int pageNumber);
+
+
+ /**
+ * 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);
+}
diff --git a/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java b/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java
new file mode 100644
index 00000000..3dbbd4d0
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024 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.profiles;
+
+/**
+ * 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/profiles/ProfileDescriptor.java b/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java
new file mode 100644
index 00000000..e2e9c19d
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2024 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.profiles;
+
+import android.view.ViewGroup;
+
+import com.android.intentresolver.v2.emptystate.EmptyStateUiHelper;
+
+import java.util.Optional;
+import java.util.function.Supplier;
+
+// 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)?
+class ProfileDescriptor<PageViewT, SinglePageAdapterT> {
+ final @MultiProfilePagerAdapter.ProfileType int mProfile;
+ final String mTabLabel;
+ final String mTabAccessibilityLabel;
+ final String mTabTag;
+
+ 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;
+
+ public SinglePageAdapterT getAdapter() {
+ return mAdapter;
+ }
+
+ public PageViewT getView() {
+ return mView;
+ }
+
+ private final PageViewT mView;
+
+ ProfileDescriptor(
+ @MultiProfilePagerAdapter.ProfileType int forProfile,
+ String tabLabel,
+ String tabAccessibilityLabel,
+ String tabTag,
+ ViewGroup rootView,
+ SinglePageAdapterT adapter,
+ Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) {
+ mProfile = forProfile;
+ mTabLabel = tabLabel;
+ mTabAccessibilityLabel = tabAccessibilityLabel;
+ mTabTag = tabTag;
+ 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;
+ }
+
+ public void setupContainerPadding() {
+ mEmptyStateUi.setupContainerPadding();
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java
index d96fd15a..e44cf8da 100644
--- a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2019 The Android Open Source Project
+ * Copyright (C) 2024 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,7 +14,7 @@
* limitations under the License.
*/
-package com.android.intentresolver.v2;
+package com.android.intentresolver.v2.profiles;
import android.content.Context;
import android.os.UserHandle;
@@ -27,7 +27,6 @@ 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;
@@ -37,40 +36,20 @@ 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,
+ ImmutableList<TabConfig<ResolverListAdapter>> tabs,
EmptyStateProvider emptyStateProvider,
Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
+ @ProfileType int defaultProfile,
UserHandle workProfileUserHandle,
UserHandle cloneProfileUserHandle) {
this(
context,
- ImmutableList.of(personalAdapter, workAdapter),
+ tabs,
emptyStateProvider,
workProfileQuietModeChecker,
defaultProfile,
@@ -81,17 +60,17 @@ public class ResolverMultiProfilePagerAdapter extends
private ResolverMultiProfilePagerAdapter(
Context context,
- ImmutableList<ResolverListAdapter> listAdapters,
+ ImmutableList<TabConfig<ResolverListAdapter>> tabs,
EmptyStateProvider emptyStateProvider,
Supplier<Boolean> workProfileQuietModeChecker,
- @Profile int defaultProfile,
+ @ProfileType int defaultProfile,
UserHandle workProfileUserHandle,
UserHandle cloneProfileUserHandle,
BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) {
super(
listAdapter -> listAdapter,
(listView, bindAdapter) -> listView.setAdapter(bindAdapter),
- listAdapters,
+ tabs,
emptyStateProvider,
workProfileQuietModeChecker,
defaultProfile,
@@ -109,11 +88,13 @@ public class ResolverMultiProfilePagerAdapter extends
/** 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);
- }
+ // TODO: The "inactive" condition is legacy logic. Could we simplify and clear-all?
+ forEachInactivePage(pageNumber -> {
+ ListView inactiveListView = getListViewForIndex(pageNumber);
+ if (inactiveListView.getCheckedItemCount() > 0) {
+ inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false);
+ }
+ });
}
private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> {
diff --git a/java/src/com/android/intentresolver/v2/profiles/TabConfig.java b/java/src/com/android/intentresolver/v2/profiles/TabConfig.java
new file mode 100644
index 00000000..994f8aff
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/profiles/TabConfig.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024 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.profiles;
+
+public class TabConfig<PageAdapterT> {
+ final @MultiProfilePagerAdapter.ProfileType int mProfile;
+ final String mTabLabel;
+ final String mTabAccessibilityLabel;
+ final String mTabTag;
+ final PageAdapterT mPageAdapter;
+
+ public TabConfig(
+ @MultiProfilePagerAdapter.ProfileType int profile,
+ String tabLabel,
+ String tabAccessibilityLabel,
+ String tabTag,
+ PageAdapterT pageAdapter) {
+ mProfile = profile;
+ mTabLabel = tabLabel;
+ mTabAccessibilityLabel = tabAccessibilityLabel;
+ mTabTag = tabTag;
+ mPageAdapter = pageAdapter;
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/shared/model/Profile.kt b/java/src/com/android/intentresolver/v2/shared/model/Profile.kt
new file mode 100644
index 00000000..6e37174c
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/shared/model/Profile.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2024 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.shared.model
+
+import com.android.intentresolver.v2.shared.model.Profile.Type
+
+/**
+ * Associates [users][User] into a [Type] instance.
+ *
+ * This is a simple abstraction which combines a primary [user][User] with an optional
+ * [cloned apps][User.Role.CLONE] user. This encapsulates the cloned app user id, while still being
+ * available where needed.
+ */
+data class Profile(
+ val type: Type,
+ val primary: User,
+ /**
+ * An optional [User] of which contains second instances of some applications installed for the
+ * personal user. This value may only be supplied when creating the PERSONAL profile.
+ */
+ val clone: User? = null
+) {
+
+ init {
+ clone?.apply {
+ require(primary.role == User.Role.PERSONAL) {
+ "clone is not supported for profile=${this@Profile.type} / primary=$primary"
+ }
+ require(role == User.Role.CLONE) { "clone is not a clone user ($this)" }
+ }
+ }
+
+ enum class Type {
+ PERSONAL,
+ WORK,
+ PRIVATE
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/data/model/User.kt b/java/src/com/android/intentresolver/v2/shared/model/User.kt
index 504b04c8..97db3280 100644
--- a/java/src/com/android/intentresolver/v2/data/model/User.kt
+++ b/java/src/com/android/intentresolver/v2/shared/model/User.kt
@@ -1,10 +1,25 @@
-package com.android.intentresolver.v2.data.model
+/*
+ * Copyright (C) 2024 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.shared.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
+import com.android.intentresolver.v2.shared.model.User.Type.FULL
+import com.android.intentresolver.v2.shared.model.User.Type.PROFILE
/**
* A User represents the owner of a distinct set of content.
diff --git a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java
index 271c6f38..a1e1c7fa 100644
--- a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java
+++ b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java
@@ -21,7 +21,6 @@ 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.
diff --git a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt
new file mode 100644
index 00000000..1cd72ba5
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2024 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.res.Resources
+import com.android.intentresolver.inject.ApplicationOwned
+import com.android.intentresolver.v2.data.repository.DevicePolicyResources
+import com.android.intentresolver.v2.shared.model.Profile
+import javax.inject.Inject
+import com.android.intentresolver.R
+
+class ProfilePagerResources
+@Inject
+constructor(
+ @ApplicationOwned private val resources: Resources,
+ private val devicePolicyResources: DevicePolicyResources
+) {
+ private val privateTabLabel by lazy { resources.getString(R.string.resolver_private_tab) }
+
+ private val privateTabAccessibilityLabel by lazy {
+ resources.getString(R.string.resolver_private_tab_accessibility)
+ }
+
+ fun profileTabLabel(profile: Profile.Type): String {
+ return when (profile) {
+ Profile.Type.PERSONAL -> devicePolicyResources.personalTabLabel
+ Profile.Type.WORK -> devicePolicyResources.workTabLabel
+ Profile.Type.PRIVATE -> privateTabLabel
+ }
+ }
+
+ fun profileTabAccessibilityLabel(type: Profile.Type): String {
+ return when (type) {
+ Profile.Type.PERSONAL -> devicePolicyResources.personalTabAccessibilityLabel
+ Profile.Type.WORK -> devicePolicyResources.workTabAccessibilityLabel
+ Profile.Type.PRIVATE -> privateTabAccessibilityLabel
+ }
+ }
+} \ No newline at end of file
diff --git a/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt
new file mode 100644
index 00000000..2b01b5e7
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2024 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.app.Activity
+import android.app.compat.CompatChanges
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.service.chooser.ChooserResult
+import android.service.chooser.ChooserResult.CHOOSER_RESULT_COPY
+import android.service.chooser.ChooserResult.CHOOSER_RESULT_EDIT
+import android.service.chooser.ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT
+import android.service.chooser.ChooserResult.CHOOSER_RESULT_UNKNOWN
+import android.service.chooser.ChooserResult.ResultType
+import android.util.Log
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.inject.ChooserServiceFlags
+import com.android.intentresolver.inject.Main
+import com.android.intentresolver.v2.ui.model.ShareAction
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.qualifiers.ActivityContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private const val TAG = "ShareResultSender"
+
+/** Reports the result of a share to another process across binder, via an [IntentSender] */
+interface ShareResultSender {
+ /** Reports user selection of an activity to launch from the provided choices. */
+ fun onComponentSelected(component: ComponentName, directShare: Boolean)
+
+ /** Reports user invocation of a built-in system action. See [ShareAction]. */
+ fun onActionSelected(action: ShareAction)
+}
+
+@AssistedFactory
+interface ShareResultSenderFactory {
+ fun create(callerUid: Int, chosenComponentSender: IntentSender): ShareResultSenderImpl
+}
+
+/** Dispatches Intents via IntentSender */
+fun interface IntentSenderDispatcher {
+ fun dispatchIntent(intentSender: IntentSender, intent: Intent)
+}
+
+class ShareResultSenderImpl(
+ private val flags: ChooserServiceFlags,
+ @Main private val scope: CoroutineScope,
+ @Background val backgroundDispatcher: CoroutineDispatcher,
+ private val callerUid: Int,
+ private val resultSender: IntentSender,
+ private val intentDispatcher: IntentSenderDispatcher
+) : ShareResultSender {
+ @AssistedInject
+ constructor(
+ @ActivityContext context: Context,
+ flags: ChooserServiceFlags,
+ @Main scope: CoroutineScope,
+ @Background backgroundDispatcher: CoroutineDispatcher,
+ @Assisted callerUid: Int,
+ @Assisted chosenComponentSender: IntentSender,
+ ) : this(
+ flags,
+ scope,
+ backgroundDispatcher,
+ callerUid,
+ chosenComponentSender,
+ IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) }
+ )
+
+ override fun onComponentSelected(component: ComponentName, directShare: Boolean) {
+ Log.i(TAG, "onComponentSelected: $component directShare=$directShare")
+ scope.launch {
+ val intent = createChosenComponentIntent(component, directShare)
+ intentDispatcher.dispatchIntent(resultSender, intent)
+ }
+ }
+
+ override fun onActionSelected(action: ShareAction) {
+ Log.i(TAG, "onActionSelected: $action")
+ scope.launch {
+ if (flags.enableChooserResult() && chooserResultSupported(callerUid)) {
+ @ResultType val chosenAction = shareActionToChooserResult(action)
+ val intent: Intent = createSelectedActionIntent(chosenAction)
+ intentDispatcher.dispatchIntent(resultSender, intent)
+ } else {
+ Log.i(TAG, "Not sending SelectedAction")
+ }
+ }
+ }
+
+ private suspend fun createChosenComponentIntent(
+ component: ComponentName,
+ direct: Boolean,
+ ): Intent {
+ // Add extra with component name for backwards compatibility.
+ val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component)
+
+ // Add ChooserResult value for Android V+
+ if (flags.enableChooserResult() && chooserResultSupported(callerUid)) {
+ intent.putExtra(
+ Intent.EXTRA_CHOOSER_RESULT,
+ ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct)
+ )
+ } else {
+ Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}")
+ }
+ return intent
+ }
+
+ @ResultType
+ private fun shareActionToChooserResult(action: ShareAction) =
+ when (action) {
+ ShareAction.SYSTEM_COPY -> CHOOSER_RESULT_COPY
+ ShareAction.SYSTEM_EDIT -> CHOOSER_RESULT_EDIT
+ ShareAction.APPLICATION_DEFINED -> CHOOSER_RESULT_UNKNOWN
+ }
+
+ private fun createSelectedActionIntent(@ResultType result: Int): Intent {
+ return Intent().putExtra(Intent.EXTRA_CHOOSER_RESULT, ChooserResult(result, null, false))
+ }
+
+ private suspend fun chooserResultSupported(uid: Int): Boolean {
+ return withContext(backgroundDispatcher) {
+ // background -> Binder call to system_server
+ CompatChanges.isChangeEnabled(ChooserResult.SEND_CHOOSER_RESULT, uid)
+ }
+ }
+}
+
+private fun IntentSender.dispatchIntent(context: Context, intent: Intent) {
+ try {
+ sendIntent(
+ /* context = */ context,
+ /* code = */ Activity.RESULT_OK,
+ /* intent = */ intent,
+ /* onFinished = */ null,
+ /* handler = */ null
+ )
+ } catch (e: IntentSender.SendIntentException) {
+ Log.e(TAG, "Failed to send intent to IntentSender", e)
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt
new file mode 100644
index 00000000..07b17435
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2024 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.model
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Parcel
+import android.os.Parcelable
+import com.android.intentresolver.v2.ext.readParcelable
+import com.android.intentresolver.v2.ext.requireParcelable
+import java.util.Objects
+
+/** Contains Activity-scope information about the state when started. */
+data class ActivityModel(
+ /** The [Intent] received by the app */
+ val intent: Intent,
+ /** The identifier for the sending app and user */
+ val launchedFromUid: Int,
+ /** The package of the sending app */
+ val launchedFromPackage: String,
+ /** The referrer as supplied to the activity. */
+ val referrer: Uri?
+) : Parcelable {
+ constructor(
+ source: Parcel
+ ) : this(
+ intent = source.requireParcelable(),
+ launchedFromUid = source.readInt(),
+ launchedFromPackage = requireNotNull(source.readString()),
+ referrer = source.readParcelable()
+ )
+
+ /** A package name from referrer, if it is an android-app URI */
+ val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority
+
+ override fun describeContents() = 0 /* flags */
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ dest.writeParcelable(intent, flags)
+ dest.writeInt(launchedFromUid)
+ dest.writeString(launchedFromPackage)
+ dest.writeParcelable(referrer, flags)
+ }
+
+ companion object {
+ const val ACTIVITY_MODEL_KEY = "com.android.intentresolver.ACTIVITY_MODEL"
+
+ @JvmField
+ @Suppress("unused")
+ val CREATOR =
+ object : Parcelable.Creator<ActivityModel> {
+ override fun newArray(size: Int) = arrayOfNulls<ActivityModel>(size)
+ override fun createFromParcel(source: Parcel) = ActivityModel(source)
+ }
+
+ @JvmStatic
+ fun createFrom(activity: Activity): ActivityModel {
+ return ActivityModel(
+ activity.intent,
+ activity.launchedFromUid,
+ Objects.requireNonNull<String>(activity.launchedFromPackage),
+ activity.referrer
+ )
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt
new file mode 100644
index 00000000..4f3cf3cd
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt
@@ -0,0 +1,209 @@
+/*
+ * Copyright (C) 2024 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.model
+
+import android.content.ComponentName
+import android.content.Intent
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.ACTION_SEND_MULTIPLE
+import android.content.Intent.EXTRA_REFERRER
+import android.content.IntentFilter
+import android.content.IntentSender
+import android.net.Uri
+import android.os.Bundle
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserTarget
+import androidx.annotation.StringRes
+import com.android.intentresolver.ContentTypeHint
+import com.android.intentresolver.v2.ext.hasAction
+
+const val ANDROID_APP_SCHEME = "android-app"
+
+/** All of the things that are consumed from an incoming share Intent (+Extras). */
+data class ChooserRequest(
+ /** Required. Represents the content being sent. */
+ val targetIntent: Intent,
+
+ /** The action from [targetIntent] as retrieved with [Intent.getAction]. */
+ val targetAction: String?,
+
+ /**
+ * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the
+ * canonical "Share" actions. When handling other actions, this flag controls behavioral and
+ * visual changes.
+ */
+ val isSendActionTarget: Boolean,
+
+ /** The top-level content type as retrieved using [Intent.getType]. */
+ val targetType: String?,
+
+ /** The package name of the app which started the current activity instance. */
+ val launchedFromPackage: String,
+
+ /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */
+ val title: CharSequence? = null,
+
+ /** A String resource ID to load when [title] is null. */
+ @get:StringRes val defaultTitleResource: Int = 0,
+
+ /**
+ * The referrer value as received by the caller. It may have been supplied via [EXTRA_REFERRER]
+ * or synthesized from callerPackageName. This value is merged into outgoing intents.
+ */
+ val referrer: Uri?,
+
+ /**
+ * Choices to exclude from results.
+ *
+ * Any resolved intents with a component in this list will be omitted before presentation.
+ */
+ val filteredComponentNames: List<ComponentName> = emptyList(),
+
+ /**
+ * App provided shortcut share intents (aka "direct share targets")
+ *
+ * Normally share shortcuts are published and consumed using
+ * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow
+ * apps to directly inject the same information.
+ *
+ * Historical note: This option was initially integrated with other results from the
+ * ChooserTargetService API (since deprecated and removed), hence the name and data format.
+ * These are more correctly called "Share Shortcuts" now.
+ */
+ val callerChooserTargets: List<ChooserTarget> = emptyList(),
+
+ /**
+ * Actions the user may perform. These are presented as separate affordances from the main list
+ * of choices. Selecting a choice is a terminal action which results in finishing. The item
+ * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate.
+ */
+ val chooserActions: List<ChooserAction> = emptyList(),
+
+ /**
+ * An action to start an Activity which for user updating of shared content. Selection is a
+ * terminal action, closing the current activity and launching the target of the action.
+ */
+ val modifyShareAction: ChooserAction? = null,
+
+ /**
+ * When false the host activity will be [finished][android.app.Activity.finish] when stopped.
+ */
+ @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false,
+
+ /**
+ * Intents which contain alternate representations of the content being shared. Any results from
+ * resolving these _alternate_ intents are included with the results of the primary intent as
+ * additional choices (e.g. share as image content vs. link to content).
+ */
+ val additionalTargets: List<Intent> = emptyList(),
+
+ /**
+ * Alternate [extras][Intent.getExtras] to substitute when launching a selected app.
+ *
+ * For a given app (by package name), the Bundle describes what parameters to substitute when
+ * that app is selected.
+ *
+ * // TODO: Map<String, Bundle>
+ */
+ val replacementExtras: Bundle? = null,
+
+ /**
+ * App-supplied choices to be presented first in the list.
+ *
+ * Custom labels and icons may be supplied using
+ * [LabeledIntent][android.content.pm.LabeledIntent].
+ *
+ * Limit 2.
+ */
+ val initialIntents: List<Intent> = emptyList(),
+
+ /**
+ * Provides for callers to be notified when a component is selected.
+ *
+ * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the
+ * [ComponentName] of the item.
+ */
+ val chosenComponentSender: IntentSender? = null,
+
+ /**
+ * Provides a mechanism for callers to post-process a target when a selection is made.
+ *
+ * The received intent will contain:
+ * * **EXTRA_INTENT** The chosen target
+ * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target
+ * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a
+ * mechanism for the caller to return information. An updated intent to send must be included
+ * as [Intent.EXTRA_INTENT].
+ */
+ val refinementIntentSender: IntentSender? = null,
+
+ /**
+ * Contains the text content to share supplied by the source app.
+ *
+ * TODO: Constrain length?
+ */
+ val sharedText: CharSequence? = null,
+
+ /**
+ * Supplied to
+ * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to
+ * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType]
+ * are considered for matching share shortcuts currently.
+ */
+ val shareTargetFilter: IntentFilter? = null,
+
+ /** A URI for additional content */
+ val additionalContentUri: Uri? = null,
+
+ /** Focused item index (from target intent's STREAM_EXTRA) */
+ val focusedItemPosition: Int = 0,
+
+ /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */
+ val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE,
+
+ /**
+ * Metadata to be shown to the user as a part of the sharesheet window.
+ *
+ * Specified by the [Intent.EXTRA_METADATA_TEXT]
+ */
+ val metadataText: CharSequence? = null,
+) {
+ val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority
+
+ fun getReferrerFillInIntent(): Intent {
+ return Intent().apply {
+ referrerPackage?.also { pkg ->
+ putExtra(EXTRA_REFERRER, Uri.parse("$ANDROID_APP_SCHEME://$pkg"))
+ }
+ }
+ }
+
+ val payloadIntents = listOf(targetIntent) + additionalTargets
+
+ /** Constructs an instance from only the required values. */
+ constructor(
+ targetIntent: Intent,
+ launchedFromPackage: String,
+ referrer: Uri?
+ ) : this(
+ targetIntent = targetIntent,
+ targetAction = targetIntent.action,
+ isSendActionTarget = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE),
+ targetType = targetIntent.type,
+ launchedFromPackage = launchedFromPackage,
+ referrer = referrer
+ )
+}
diff --git a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt
new file mode 100644
index 00000000..a4f74ca9
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 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.model
+
+import android.content.Intent
+import android.content.pm.ResolveInfo
+import android.os.UserHandle
+import com.android.intentresolver.v2.shared.model.Profile
+import com.android.intentresolver.v2.ext.isHomeIntent
+
+/** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */
+data class ResolverRequest(
+ /** The intent to be resolved to a target. */
+ val intent: Intent,
+
+ /**
+ * Supplied by the system to indicate which profile should be selected by default. This is
+ * required since ResolverActivity may be launched as either the originating OR target user when
+ * resolving a cross profile intent.
+ *
+ * Valid values are: [PERSONAL][Profile.Type.PERSONAL] and [WORK][Profile.Type.WORK] and null
+ * when the intent is not a forwarded cross-profile intent.
+ */
+ val selectedProfile: Profile.Type?,
+
+ /**
+ * When handing a cross profile forwarded intent, this is the user which started the original
+ * intent. This is required to allow ResolverActivity to be launched as the target user under
+ * some conditions.
+ */
+ val callingUser: UserHandle?,
+
+ /**
+ * Indicates if resolving actions for a connected device which has audio capture capability
+ * (e.g. is a USB Microphone).
+ *
+ * When used to handle a connected device, ResolverActivity uses this signal to present a
+ * warning when a resolved application does not hold the RECORD_AUDIO permission. (If selected
+ * the app would be able to capture audio directly via the device, bypassing audio API
+ * permissions.)
+ */
+ val isAudioCaptureDevice: Boolean = false,
+
+ /** A list of a resolved activity targets. This list overrides normal intent resolution. */
+ val resolutionList: List<ResolveInfo>? = null,
+
+ /** A customized title for the resolver interface. */
+ val title: String? = null,
+) {
+ val isResolvingHome = intent.isHomeIntent()
+
+ /** For compatibility with existing code shared between chooser/resolver. */
+ val payloadIntents: List<Intent> = listOf(intent)
+}
diff --git a/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt b/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt
new file mode 100644
index 00000000..e13ef101
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2024 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.model
+
+enum class ShareAction {
+ SYSTEM_COPY,
+ SYSTEM_EDIT,
+ APPLICATION_DEFINED
+}
diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt
new file mode 100644
index 00000000..91eed408
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2024 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.viewmodel
+
+import android.content.ComponentName
+import android.content.Intent
+import android.content.Intent.EXTRA_ALTERNATE_INTENTS
+import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS
+import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION
+import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER
+import android.content.Intent.EXTRA_CHOOSER_TARGETS
+import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER
+import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS
+import android.content.Intent.EXTRA_INITIAL_INTENTS
+import android.content.Intent.EXTRA_INTENT
+import android.content.Intent.EXTRA_METADATA_TEXT
+import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS
+import android.content.Intent.EXTRA_TEXT
+import android.content.Intent.EXTRA_TITLE
+import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK
+import android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT
+import android.content.IntentFilter
+import android.content.IntentSender
+import android.net.Uri
+import android.os.Bundle
+import android.service.chooser.ChooserAction
+import android.service.chooser.ChooserTarget
+import com.android.intentresolver.ChooserActivity
+import com.android.intentresolver.ContentTypeHint
+import com.android.intentresolver.R
+import com.android.intentresolver.inject.ChooserServiceFlags
+import com.android.intentresolver.util.hasValidIcon
+import com.android.intentresolver.v2.ext.hasSendAction
+import com.android.intentresolver.v2.ext.ifMatch
+import com.android.intentresolver.v2.ui.model.ActivityModel
+import com.android.intentresolver.v2.ui.model.ChooserRequest
+import com.android.intentresolver.v2.validation.Validation
+import com.android.intentresolver.v2.validation.ValidationResult
+import com.android.intentresolver.v2.validation.types.IntentOrUri
+import com.android.intentresolver.v2.validation.types.array
+import com.android.intentresolver.v2.validation.types.value
+import com.android.intentresolver.v2.validation.validateFrom
+
+private const val MAX_CHOOSER_ACTIONS = 5
+private const val MAX_INITIAL_INTENTS = 2
+
+internal fun Intent.maybeAddSendActionFlags() =
+ ifMatch(Intent::hasSendAction) {
+ addFlags(FLAG_ACTIVITY_NEW_DOCUMENT)
+ addFlags(FLAG_ACTIVITY_MULTIPLE_TASK)
+ }
+
+fun readChooserRequest(
+ launch: ActivityModel,
+ flags: ChooserServiceFlags
+): ValidationResult<ChooserRequest> {
+ val extras = launch.intent.extras ?: Bundle()
+ @Suppress("DEPRECATION")
+ return validateFrom(extras::get) {
+ val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags()
+
+ val isSendAction = targetIntent.hasSendAction()
+
+ val additionalTargets = readAlternateIntents() ?: emptyList()
+
+ val replacementExtras = optional(value<Bundle>(EXTRA_REPLACEMENT_EXTRAS))
+
+ val (customTitle, defaultTitleResource) =
+ if (isSendAction) {
+ ignored(
+ value<CharSequence>(EXTRA_TITLE),
+ "deprecated in P. You may wish to set a preview title by using EXTRA_TITLE " +
+ "property of the wrapped EXTRA_INTENT."
+ )
+ null to R.string.chooseActivity
+ } else {
+ val custom = optional(value<CharSequence>(EXTRA_TITLE))
+ custom to (custom?.let { 0 } ?: R.string.chooseActivity)
+ }
+
+ val initialIntents =
+ optional(array<Intent>(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map {
+ it.maybeAddSendActionFlags()
+ }
+ ?: emptyList()
+
+ val chosenComponentSender =
+ optional(value<IntentSender>(EXTRA_CHOOSER_RESULT_INTENT_SENDER))
+ ?: optional(value<IntentSender>(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER))
+
+ val refinementIntentSender =
+ optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER))
+
+ val filteredComponents =
+ optional(array<ComponentName>(EXTRA_EXCLUDE_COMPONENTS)) ?: emptyList()
+
+ @Suppress("DEPRECATION")
+ val callerChooserTargets =
+ optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS)) ?: emptyList()
+
+ val retainInOnStop =
+ optional(value<Boolean>(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false
+
+ val sharedText = optional(value<CharSequence>(EXTRA_TEXT))
+
+ val chooserActions = readChooserActions() ?: emptyList()
+
+ val modifyShareAction = optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION))
+
+ val additionalContentUri: Uri?
+ val focusedItemPos: Int
+ if (isSendAction && flags.chooserPayloadToggling()) {
+ additionalContentUri = optional(value<Uri>(Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI))
+ focusedItemPos = optional(value<Int>(Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0
+ } else {
+ additionalContentUri = null
+ focusedItemPos = 0
+ }
+
+ val contentTypeHint =
+ if (flags.chooserAlbumText()) {
+ when (optional(value<Int>(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) {
+ Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM
+ else -> ContentTypeHint.NONE
+ }
+ } else {
+ ContentTypeHint.NONE
+ }
+
+ val metadataText =
+ if (flags.enableSharesheetMetadataExtra()) {
+ optional(value<CharSequence>(EXTRA_METADATA_TEXT))
+ } else {
+ null
+ }
+
+ ChooserRequest(
+ targetIntent = targetIntent,
+ targetAction = targetIntent.action,
+ isSendActionTarget = isSendAction,
+ targetType = targetIntent.type,
+ launchedFromPackage =
+ requireNotNull(launch.launchedFromPackage) {
+ "launch.fromPackage was null, See Activity.getLaunchedFromPackage()"
+ },
+ title = customTitle,
+ defaultTitleResource = defaultTitleResource,
+ referrer = launch.referrer,
+ filteredComponentNames = filteredComponents,
+ callerChooserTargets = callerChooserTargets,
+ chooserActions = chooserActions,
+ modifyShareAction = modifyShareAction,
+ shouldRetainInOnStop = retainInOnStop,
+ additionalTargets = additionalTargets,
+ replacementExtras = replacementExtras,
+ initialIntents = initialIntents,
+ chosenComponentSender = chosenComponentSender,
+ refinementIntentSender = refinementIntentSender,
+ sharedText = sharedText,
+ shareTargetFilter = targetIntent.toShareTargetFilter(),
+ additionalContentUri = additionalContentUri,
+ focusedItemPosition = focusedItemPos,
+ contentTypeHint = contentTypeHint,
+ metadataText = metadataText,
+ )
+ }
+}
+
+fun Validation.readAlternateIntents(): List<Intent>? =
+ optional(array<Intent>(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() }
+
+fun Validation.readChooserActions(): List<ChooserAction>? =
+ optional(array<ChooserAction>(EXTRA_CHOOSER_CUSTOM_ACTIONS))
+ ?.filter { hasValidIcon(it) }
+ ?.take(MAX_CHOOSER_ACTIONS)
+
+private fun Intent.toShareTargetFilter(): IntentFilter? {
+ return type?.let {
+ IntentFilter().apply {
+ action?.also { addAction(it) }
+ addDataType(it)
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt
new file mode 100644
index 00000000..8ed2fa29
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2024 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.viewmodel
+
+import android.util.Log
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import com.android.intentresolver.inject.ChooserServiceFlags
+import com.android.intentresolver.v2.ui.model.ActivityModel
+import com.android.intentresolver.v2.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY
+import com.android.intentresolver.v2.ui.model.ChooserRequest
+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.log
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+private const val TAG = "ChooserViewModel"
+
+@HiltViewModel
+class ChooserViewModel
+@Inject
+constructor(
+ args: SavedStateHandle,
+ flags: ChooserServiceFlags,
+) : ViewModel() {
+
+ /** Parcelable-only references provided from the creating Activity */
+ val activityModel: ActivityModel =
+ requireNotNull(args[ACTIVITY_MODEL_KEY]) {
+ "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)"
+ }
+
+ /** The result of reading and validating the inputs provided in savedState. */
+ private val status: ValidationResult<ChooserRequest> = readChooserRequest(activityModel, flags)
+
+ val chooserRequest: ChooserRequest by lazy {
+ when (status) {
+ is Valid -> status.value
+ is Invalid -> error(status.errors)
+ }
+ }
+
+ fun init(): Boolean {
+ Log.i(TAG, "viewModel init")
+ if (status is Invalid) {
+ status.errors.forEach { finding -> finding.log(TAG) }
+ return false
+ }
+ Log.i(TAG, "request = $chooserRequest")
+ return true
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt
new file mode 100644
index 00000000..bbc376ea
--- /dev/null
+++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024 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.viewmodel
+
+import android.os.Bundle
+import android.os.UserHandle
+import com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL
+import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK
+import com.android.intentresolver.v2.shared.model.Profile
+import com.android.intentresolver.v2.ui.model.ActivityModel
+import com.android.intentresolver.v2.ui.model.ResolverRequest
+import com.android.intentresolver.v2.validation.Validation
+import com.android.intentresolver.v2.validation.ValidationResult
+import com.android.intentresolver.v2.validation.types.value
+import com.android.intentresolver.v2.validation.validateFrom
+
+const val EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"
+const val EXTRA_SELECTED_PROFILE =
+ "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE"
+const val EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"
+
+fun readResolverRequest(launch: ActivityModel): ValidationResult<ResolverRequest> {
+ @Suppress("DEPRECATION")
+ return validateFrom((launch.intent.extras ?: Bundle())::get) {
+ val callingUser = optional(value<UserHandle>(EXTRA_CALLING_USER))
+ val selectedProfile = checkSelectedProfile()
+ val audioDevice = optional(value<Boolean>(EXTRA_IS_AUDIO_CAPTURE_DEVICE)) ?: false
+ ResolverRequest(launch.intent, selectedProfile, callingUser, audioDevice)
+ }
+}
+
+private fun Validation.checkSelectedProfile(): Profile.Type? {
+ return when (val selected = optional(value<Int>(EXTRA_SELECTED_PROFILE))) {
+ null -> null
+ PROFILE_PERSONAL -> Profile.Type.PERSONAL
+ PROFILE_WORK -> Profile.Type.WORK
+ else ->
+ error(
+ EXTRA_SELECTED_PROFILE +
+ " has invalid value ($selected)." +
+ " Must be either ResolverActivity.PROFILE_PERSONAL ($PROFILE_PERSONAL)" +
+ " or ResolverActivity.PROFILE_WORK ($PROFILE_WORK)."
+ )
+ }
+}
diff --git a/java/src/com/android/intentresolver/v2/validation/Findings.kt b/java/src/com/android/intentresolver/v2/validation/Findings.kt
index 9a3cc9c7..bdf2f00a 100644
--- a/java/src/com/android/intentresolver/v2/validation/Findings.kt
+++ b/java/src/com/android/intentresolver/v2/validation/Findings.kt
@@ -34,9 +34,13 @@ val Finding.logcatPriority
get() =
when (importance) {
CRITICAL -> Log.ERROR
- else -> Log.WARN
+ WARNING -> Log.WARN
}
+fun Finding.log(tag: String) {
+ Log.println(logcatPriority, tag, message)
+}
+
private fun formatMessage(key: String? = null, msg: String) = buildString {
key?.also { append("['$key']: ") }
append(msg)
@@ -52,18 +56,21 @@ data class IgnoredValue(
get() = formatMessage(key, "Ignored. $reason")
}
-data class RequiredValueMissing(
+data class NoValue(
val key: String,
+ override val importance: Importance,
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"
+ if (importance == CRITICAL) {
+ "expected value of ${allowedType.simpleName}, " + "but no value was present"
+ } else {
+ "no ${allowedType.simpleName} value present"
+ }
)
}
diff --git a/java/src/com/android/intentresolver/v2/validation/Validation.kt b/java/src/com/android/intentresolver/v2/validation/Validation.kt
index 46939602..6072ec9f 100644
--- a/java/src/com/android/intentresolver/v2/validation/Validation.kt
+++ b/java/src/com/android/intentresolver/v2/validation/Validation.kt
@@ -90,7 +90,7 @@ fun <T> validateFrom(source: (String) -> Any?, validate: Validation.() -> T): Va
is InvalidResultError -> Invalid(validation.findings)
// Some other exception was thrown from [validate],
- else -> Invalid(findings = listOf(UncaughtException(it)))
+ else -> Invalid(error = UncaughtException(it))
}
}
)
@@ -107,8 +107,8 @@ private class ValidationImpl(val source: (String) -> Any?) : Validation {
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.
+ if (result is Valid) {
+ // Note: Any warnings about the value itself (result.findings) are ignored.
findings += IgnoredValue(property.key, reason)
}
}
@@ -117,8 +117,16 @@ private class ValidationImpl(val source: (String) -> Any?) : Validation {
return runCatching { property.validate(source, importance) }
.fold(
onSuccess = { result ->
- findings += result.findings
- result.value
+ return when (result) {
+ is Valid -> {
+ findings += result.warnings
+ result.value
+ }
+ is Invalid -> {
+ findings += result.errors
+ null
+ }
+ }
},
onFailure = {
findings += UncaughtException(it, property.key)
diff --git a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt
index 092cabe8..f5c467dc 100644
--- a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt
+++ b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt
@@ -15,25 +15,12 @@
*/
package com.android.intentresolver.v2.validation
-import android.util.Log
+sealed interface ValidationResult<T>
-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>(val value: T, val warnings: List<Finding> = emptyList()) : ValidationResult<T> {
+ constructor(value: T, warning: Finding) : this(value, listOf(warning))
}
-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
+data class Invalid<T>(val errors: List<Finding> = emptyList()) : ValidationResult<T> {
+ constructor(error: Finding) : this(listOf(error))
}
diff --git a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt
index 3cefeb15..050bd895 100644
--- a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt
+++ b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt
@@ -18,7 +18,8 @@ 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.Invalid
+import com.android.intentresolver.v2.validation.NoValue
import com.android.intentresolver.v2.validation.Valid
import com.android.intentresolver.v2.validation.ValidationResult
import com.android.intentresolver.v2.validation.Validator
@@ -40,12 +41,14 @@ class IntentOrUri(override val key: String) : Validator<Intent> {
is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME))
// No value present.
- null -> createResult(importance, RequiredValueMissing(key, Intent::class))
+ null -> when (importance) {
+ Importance.WARNING -> Invalid() // No warnings if optional, but missing
+ Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class))
+ }
// Some other type.
else -> {
- return createResult(
- importance,
+ return Invalid(
ValueIsWrongType(
key,
importance,
diff --git a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt
index c6c4abba..78adfd36 100644
--- a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt
+++ b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt
@@ -15,8 +15,10 @@
*/
package com.android.intentresolver.v2.validation.types
+import android.content.Intent
import com.android.intentresolver.v2.validation.Importance
-import com.android.intentresolver.v2.validation.RequiredValueMissing
+import com.android.intentresolver.v2.validation.Invalid
+import com.android.intentresolver.v2.validation.NoValue
import com.android.intentresolver.v2.validation.Valid
import com.android.intentresolver.v2.validation.ValidationResult
import com.android.intentresolver.v2.validation.Validator
@@ -37,8 +39,10 @@ class ParceledArray<T : Any>(
return when (val value: Any? = source(key)) {
// No value present.
- null -> createResult(importance, RequiredValueMissing(key, elementType))
-
+ null -> when (importance) {
+ Importance.WARNING -> Invalid() // No warnings if optional, but missing
+ Importance.CRITICAL -> Invalid(NoValue(key, importance, 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>.
@@ -54,8 +58,7 @@ class ParceledArray<T : Any>(
// At least one incorrect element type found.
else ->
- createResult(
- importance,
+ Invalid(
WrongElementType(
key,
importance,
@@ -69,8 +72,7 @@ class ParceledArray<T : Any>(
// The value is not an Array at all.
else ->
- createResult(
- importance,
+ Invalid(
ValueIsWrongType(
key,
importance,
diff --git a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt
index 3287b84b..0105541d 100644
--- a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt
+++ b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt
@@ -16,7 +16,8 @@
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.Invalid
+import com.android.intentresolver.v2.validation.NoValue
import com.android.intentresolver.v2.validation.Valid
import com.android.intentresolver.v2.validation.ValidationResult
import com.android.intentresolver.v2.validation.Validator
@@ -36,19 +37,21 @@ class SimpleValue<T : Any>(
expected.isInstance(value) -> return Valid(expected.cast(value))
// No value is present.
- value == null -> createResult(importance, RequiredValueMissing(key, expected))
+ value == null -> when (importance) {
+ Importance.WARNING -> Invalid() // No warnings if optional, but missing
+ Importance.CRITICAL -> Invalid(NoValue(key, importance, expected))
+ }
// The value is some other type.
else ->
- createResult(
- importance,
+ Invalid(listOf(
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
index 4e6e5dff..70993b4d 100644
--- a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt
+++ b/java/src/com/android/intentresolver/v2/validation/types/Validators.kt
@@ -15,13 +15,6 @@
*/
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> {
@@ -31,15 +24,3 @@ inline fun <reified T : Any> value(key: String): Validator<T> {
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/BadgeTextView.kt b/java/src/com/android/intentresolver/widget/BadgeTextView.kt
new file mode 100644
index 00000000..b6cadd86
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/BadgeTextView.kt
@@ -0,0 +1,88 @@
+package com.android.intentresolver.widget
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.Gravity
+import android.widget.TextView
+
+/**
+ * A TextView that supports a badge at the end of the text. If the text, when centered in the view,
+ * leaves enough room for the badge, the badge is just displayed at the end of the view. Otherwise,
+ * the necessary amount of space for the badge is reserved and the text gets centered in the
+ * remaining free space.
+ */
+class BadgeTextView : TextView {
+ constructor(context: Context) : this(context, null)
+
+ constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+
+ constructor(
+ context: Context,
+ attrs: AttributeSet?,
+ defStyleAttr: Int
+ ) : this(context, attrs, defStyleAttr, 0)
+
+ constructor(
+ context: Context?,
+ attrs: AttributeSet?,
+ defStyleAttr: Int,
+ defStyleRes: Int
+ ) : super(context, attrs, defStyleAttr, defStyleRes) {
+ super.setGravity(Gravity.CENTER)
+ defaultPaddingLeft = paddingLeft
+ defaultPaddingRight = paddingRight
+ }
+
+ private var defaultPaddingLeft = 0
+ private var defaultPaddingRight = 0
+
+ override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
+ super.setPadding(left, top, right, bottom)
+ defaultPaddingLeft = paddingLeft
+ defaultPaddingRight = paddingRight
+ }
+
+ override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) {
+ super.setPaddingRelative(start, top, end, bottom)
+ defaultPaddingLeft = paddingLeft
+ defaultPaddingRight = paddingRight
+ }
+
+ /** Sets end-sided badge. */
+ var badgeDrawable: Drawable? = null
+ set(value) {
+ if (field !== value) {
+ field = value
+ super.setBackground(value)
+ }
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.setPadding(defaultPaddingLeft, paddingTop, defaultPaddingRight, paddingBottom)
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ val badge = badgeDrawable ?: return
+ if (badge.intrinsicWidth <= paddingEnd) return
+ var maxLineWidth = 0f
+ for (i in 0 until layout.lineCount) {
+ maxLineWidth = maxOf(maxLineWidth, layout.getLineWidth(i))
+ }
+ val sideSpace = (measuredWidth - maxLineWidth) / 2
+ if (sideSpace < badge.intrinsicWidth) {
+ super.setPaddingRelative(
+ paddingStart,
+ paddingTop,
+ paddingEnd + badge.intrinsicWidth - sideSpace.toInt(),
+ paddingBottom
+ )
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ }
+ }
+
+ override fun setBackground(background: Drawable?) {
+ badgeDrawable = null
+ super.setBackground(background)
+ }
+
+ override fun setGravity(gravity: Int): Unit = error("Not supported")
+}