summaryrefslogtreecommitdiff
path: root/java/src
diff options
context:
space:
mode:
author Xin Li <delphij@google.com> 2024-11-06 10:31:34 -0800
committer Xin Li <delphij@google.com> 2024-11-06 10:31:34 -0800
commitfca7f4058678568daa0e95f8fdf97ea69d887c7a (patch)
tree8cb98b028828ccca6bec82d1ecb80ccc56bfc519 /java/src
parent68b62d37ebe6bbc49d149e2bd5d548afe7415e23 (diff)
parent49474fb831775a91a283517e14be92088216d8b7 (diff)
Merge 24Q4 (ab/12406339) into aosp-main-future
Bug: 370570306 Merged-In: I6840e0687b78b38df7ac5d187bf147e0c5a33e24 Change-Id: I66a1a2533efa845683e66a799263fe5a18ba84b4
Diffstat (limited to 'java/src')
-rw-r--r--java/src/com/android/intentresolver/ChooserActionFactory.java12
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java190
-rw-r--r--java/src/com/android/intentresolver/ChooserHelper.kt37
-rw-r--r--java/src/com/android/intentresolver/ChooserListAdapter.java78
-rw-r--r--java/src/com/android/intentresolver/ChooserRequestParameters.java504
-rw-r--r--java/src/com/android/intentresolver/ResolverListAdapter.java48
-rw-r--r--java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt35
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt15
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java28
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java5
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt2
-rw-r--r--java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt3
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoader.kt21
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt26
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt18
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt13
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt197
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt98
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt2
-rw-r--r--java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java9
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt26
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java17
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt4
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt5
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt73
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt3
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt4
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt9
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt7
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt2
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt19
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt226
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt183
-rw-r--r--java/src/com/android/intentresolver/data/model/ChooserRequest.kt2
-rw-r--r--java/src/com/android/intentresolver/grid/ChooserGridAdapter.java8
-rw-r--r--java/src/com/android/intentresolver/logging/EventLog.kt15
-rw-r--r--java/src/com/android/intentresolver/logging/EventLogImpl.java8
-rw-r--r--java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java9
-rw-r--r--java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt116
-rw-r--r--java/src/com/android/intentresolver/ui/ShareResultSender.kt54
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt31
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt17
-rw-r--r--java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt46
-rw-r--r--java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt42
44 files changed, 1184 insertions, 1083 deletions
diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java
index cc7091e4..21ca3b73 100644
--- a/java/src/com/android/intentresolver/ChooserActionFactory.java
+++ b/java/src/com/android/intentresolver/ChooserActionFactory.java
@@ -133,8 +133,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
ActionActivityStarter activityStarter,
@Nullable ShareResultSender shareResultSender,
Consumer</* @Nullable */ Integer> finishCallback,
- ClipboardManager clipboardManager,
- FeatureFlags featureFlags) {
+ ClipboardManager clipboardManager) {
this(
context,
makeCopyButtonRunnable(
@@ -150,8 +149,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
imageEditor),
firstVisibleImageQuery,
activityStarter,
- log,
- featureFlags.fixPartialImageEditTransition()),
+ log),
chooserActions,
onUpdateSharedTextIsExcluded,
log,
@@ -340,8 +338,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
@Nullable TargetInfo editSharingTarget,
Callable</* @Nullable */ View> firstVisibleImageQuery,
ActionActivityStarter activityStarter,
- EventLog log,
- boolean requireFullVisibility) {
+ EventLog log) {
if (editSharingTarget == null) return null;
return () -> {
// Log share completion via edit.
@@ -352,8 +349,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio
firstImageView = firstVisibleImageQuery.call();
} catch (Exception e) { /* ignore */ }
// Action bar is user-independent; always start as primary.
- if (firstImageView == null
- || (requireFullVisibility && !isFullyVisible(firstImageView))) {
+ if (firstImageView == null || !isFullyVisible(firstImageView)) {
activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget);
} else {
activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index a5516fde..3db821c1 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -23,6 +23,9 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE
import static androidx.lifecycle.LifecycleKt.getCoroutineScope;
import static com.android.intentresolver.ChooserActionFactory.EDIT_SOURCE;
+import static com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra;
+import static com.android.intentresolver.Flags.fixShortcutsFlashing;
+import static com.android.intentresolver.Flags.unselectFinalItem;
import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs;
import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL;
import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK;
@@ -96,10 +99,8 @@ import com.android.intentresolver.ChooserRefinementManager.RefinementType;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
-import com.android.intentresolver.contentpreview.BasePreviewViewModel;
import com.android.intentresolver.contentpreview.ChooserContentPreviewUi;
import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl;
-import com.android.intentresolver.contentpreview.PreviewViewModel;
import com.android.intentresolver.data.model.ChooserRequest;
import com.android.intentresolver.data.repository.DevicePolicyResources;
import com.android.intentresolver.domain.interactor.UserInteractor;
@@ -154,8 +155,10 @@ import kotlinx.coroutines.CoroutineDispatcher;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -206,7 +209,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
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";
+ private static final String LAST_SHOWN_PROFILE = "last_shown_tab_key";
public static final String METRICS_CATEGORY_CHOOSER = "intent_chooser";
private int mLayoutId;
@@ -306,7 +309,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate =
new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout);
- private final Map<Integer, ProfileRecord> mProfileRecords = new HashMap<>();
+ private final Map<Integer, ProfileRecord> mProfileRecords = new LinkedHashMap<>();
private boolean mExcludeSharedText = false;
/**
@@ -349,8 +352,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (mChooserServiceFeatureFlags.chooserPayloadToggling()) {
mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged);
mChooserHelper.setOnPendingSelection(this::onPendingSelection);
+ if (unselectFinalItem()) {
+ mChooserHelper.setOnHasSelections(this::onHasSelections);
+ }
}
}
+ private int mInitialProfile = -1;
@Override
protected final void onStart() {
@@ -412,7 +419,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
protected final void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
if (mViewPager != null) {
- outState.putInt(LAST_SHOWN_TAB_KEY, mViewPager.getCurrentItem());
+ outState.putInt(
+ LAST_SHOWN_PROFILE, mChooserMultiProfilePagerAdapter.getActiveProfile());
}
}
@@ -517,6 +525,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mProfilePagerResources,
mRequest,
mProfiles,
+ mProfileRecords.values(),
mProfileAvailability,
mRequest.getInitialIntents(),
mMaxTargetsPerRow);
@@ -633,21 +642,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
finish();
}
});
- BasePreviewViewModel previewViewModel =
- new ViewModelProvider(this, createPreviewViewModelFactory())
- .get(BasePreviewViewModel.class);
- previewViewModel.init(
- mRequest.getTargetIntent(),
- mRequest.getAdditionalContentUri(),
- mChooserServiceFeatureFlags.chooserPayloadToggling());
ChooserContentPreviewUi.ActionFactory actionFactory =
decorateActionFactoryWithRefinement(
createChooserActionFactory(mRequest.getTargetIntent()));
mChooserContentPreviewUi = new ChooserContentPreviewUi(
getCoroutineScope(getLifecycle()),
- previewViewModel.getPreviewDataProvider(),
- mRequest.getTargetIntent(),
- previewViewModel.getImageLoader(),
+ mViewModel.getPreviewDataProvider(),
+ mRequest,
+ mViewModel.getImageLoader(),
actionFactory,
createModifyShareActionFactory(),
mEnterTransitionAnimationDelegate,
@@ -688,6 +690,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mRequest.getModifyShareAction() != null
);
mEnterTransitionAnimationDelegate.postponeTransition();
+ mInitialProfile = findSelectedProfile();
Tracer.INSTANCE.markLaunched();
}
@@ -706,7 +709,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private void onChooserRequestChanged(ChooserRequest chooserRequest) {
- // intentional reference comparison
if (mRequest == chooserRequest) {
return;
}
@@ -725,6 +727,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
setTabsViewEnabled(false);
}
+ private void onHasSelections(boolean hasSelections) {
+ mChooserMultiProfilePagerAdapter.setTargetsEnabled(hasSelections);
+ }
+
private void onAppTargetsLoaded(ResolverListAdapter listAdapter) {
Log.d(TAG, "onAppTargetsLoaded("
+ "listAdapter.userHandle=" + listAdapter.getUserHandle() + ")");
@@ -755,10 +761,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
Intent newTargetIntent = newChooserRequest.getTargetIntent();
List<Intent> oldAltIntents = oldChooserRequest.getAdditionalTargets();
List<Intent> newAltIntents = newChooserRequest.getAdditionalTargets();
+ List<ComponentName> oldExcluded = oldChooserRequest.getFilteredComponentNames();
+ List<ComponentName> newExcluded = newChooserRequest.getFilteredComponentNames();
// TODO: a workaround for the unnecessary target reloading caused by multiple flow updates -
// an artifact of the current implementation; revisit.
- return !oldTargetIntent.equals(newTargetIntent) || !oldAltIntents.equals(newAltIntents);
+ return !oldTargetIntent.equals(newTargetIntent)
+ || !oldAltIntents.equals(newAltIntents)
+ || (shareouselUpdateExcludeComponentsExtra()
+ && !oldExcluded.equals(newExcluded));
}
private void recreatePagerAdapter() {
@@ -782,11 +793,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
// Update the pager adapter but do not attach it to the view till the targets are reloaded,
// see onChooserAppTargetsLoaded method.
+ ChooserMultiProfilePagerAdapter oldPagerAdapter =
+ mChooserMultiProfilePagerAdapter;
mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter(
/* context = */ this,
mProfilePagerResources,
mRequest,
mProfiles,
+ mProfileRecords.values(),
mProfileAvailability,
mRequest.getInitialIntents(),
mMaxTargetsPerRow);
@@ -820,6 +834,19 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
postRebuildList(
mChooserMultiProfilePagerAdapter.rebuildTabs(
mProfiles.getWorkProfilePresent() || mProfiles.getPrivateProfilePresent()));
+ if (fixShortcutsFlashing() && oldPagerAdapter != null) {
+ for (int i = 0, count = mChooserMultiProfilePagerAdapter.getCount(); i < count; i++) {
+ ChooserListAdapter listAdapter =
+ mChooserMultiProfilePagerAdapter.getPageAdapterForIndex(i)
+ .getListAdapter();
+ ChooserListAdapter oldListAdapter =
+ oldPagerAdapter.getListAdapterForUserHandle(listAdapter.getUserHandle());
+ if (oldListAdapter != null) {
+ listAdapter.copyDirectTargetsFrom(oldListAdapter);
+ listAdapter.setDirectTargetsEnabled(false);
+ }
+ }
+ }
setTabsViewEnabled(false);
}
@@ -837,7 +864,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
@Override
protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
if (mViewPager != null) {
- mViewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY));
+ int profile = savedInstanceState.getInt(LAST_SHOWN_PROFILE);
+ int profileNumber = mChooserMultiProfilePagerAdapter.getPageNumberForProfile(profile);
+ if (profileNumber != -1) {
+ mViewPager.setCurrentItem(profileNumber);
+ mInitialProfile = profile;
+ }
}
mChooserMultiProfilePagerAdapter.clearInactiveProfileCache();
}
@@ -1088,7 +1120,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (cti.startAsCaller(this, options, user.getIdentifier())) {
// Prevent sending a second chooser result when starting the edit action intent.
if (!cti.getTargetIntent().hasExtra(EDIT_SOURCE)) {
- maybeSendShareResult(cti);
+ maybeSendShareResult(cti, user);
}
maybeLogCrossProfileTargetLaunch(cti, user);
}
@@ -1346,26 +1378,32 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
private void createProfileRecords(
AppPredictorFactory factory, IntentFilter targetIntentFilter) {
- UserHandle mainUserHandle = mProfiles.getPersonalHandle();
- ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory);
- if (record.shortcutLoader == null) {
- Tracer.INSTANCE.endLaunchToShortcutTrace();
- }
-
- UserHandle workUserHandle = mProfiles.getWorkHandle();
- if (workUserHandle != null) {
- createProfileRecord(workUserHandle, targetIntentFilter, factory);
- }
- UserHandle privateUserHandle = mProfiles.getPrivateHandle();
- if (privateUserHandle != null && mProfileAvailability.isAvailable(
- requireNonNull(mProfiles.getPrivateProfile()))) {
- createProfileRecord(privateUserHandle, targetIntentFilter, factory);
+ Profile launchedAsProfile = mProfiles.getLaunchedAsProfile();
+ for (Profile profile : mProfiles.getProfiles()) {
+ if (profile.getType() == Profile.Type.PRIVATE
+ && !mProfileAvailability.isAvailable(profile)) {
+ continue;
+ }
+ ProfileRecord record = createProfileRecord(
+ profile,
+ targetIntentFilter,
+ launchedAsProfile.equals(profile)
+ ? mRequest.getCallerChooserTargets()
+ : Collections.emptyList(),
+ factory);
+ if (profile.equals(launchedAsProfile) && record.shortcutLoader == null) {
+ Tracer.INSTANCE.endLaunchToShortcutTrace();
+ }
}
}
private ProfileRecord createProfileRecord(
- UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) {
+ Profile profile,
+ IntentFilter targetIntentFilter,
+ List<ChooserTarget> callerTargets,
+ AppPredictorFactory factory) {
+ UserHandle userHandle = profile.getPrimary().getHandle();
AppPredictor appPredictor = factory.create(userHandle);
ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic()
? null
@@ -1375,7 +1413,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
userHandle,
targetIntentFilter,
shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult));
- ProfileRecord record = new ProfileRecord(appPredictor, shortcutLoader);
+ ProfileRecord record = new ProfileRecord(
+ profile, appPredictor, shortcutLoader, callerTargets);
mProfileRecords.put(userHandle.getIdentifier(), record);
return record;
}
@@ -1410,6 +1449,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
ProfilePagerResources profilePagerResources,
ChooserRequest request,
ProfileHelper profileHelper,
+ Collection<ProfileRecord> profileRecords,
ProfileAvailability profileAvailability,
List<Intent> initialIntents,
int maxTargetsPerRow) {
@@ -1421,11 +1461,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
List<Intent> payloadIntents = request.getPayloadIntents();
List<TabConfig<ChooserGridAdapter>> tabs = new ArrayList<>();
- for (Profile profile : profileHelper.getProfiles()) {
- if (profile.getType() == Profile.Type.PRIVATE
- && !profileAvailability.isAvailable(profile)) {
- continue;
- }
+ for (ProfileRecord record : profileRecords) {
+ Profile profile = record.profile;
ChooserGridAdapter adapter = createChooserGridAdapter(
context,
payloadIntents,
@@ -1640,26 +1677,29 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
return result;
}
- private void maybeSendShareResult(TargetInfo cti) {
+ private void maybeSendShareResult(TargetInfo cti, UserHandle launchedAsUser) {
if (mShareResultSender != null) {
final ComponentName target = cti.getResolvedComponentName();
if (target != null) {
- mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo());
+ boolean crossProfile = !UserHandle.of(UserHandle.myUserId()).equals(launchedAsUser);
+ mShareResultSender.onComponentSelected(
+ target, cti.isChooserTargetInfo(), crossProfile);
}
}
}
- private void addCallerChooserTargets() {
- if (!mRequest.getCallerChooserTargets().isEmpty()) {
- // Send the caller's chooser targets only to the default profile.
- if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) {
- mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults(
- /* origTarget */ null,
- new ArrayList<>(mRequest.getCallerChooserTargets()),
- TARGET_TYPE_DEFAULT,
- /* directShareShortcutInfoCache */ Collections.emptyMap(),
- /* directShareAppTargetCache */ Collections.emptyMap());
- }
+ private void addCallerChooserTargets(ChooserListAdapter adapter) {
+ ProfileRecord record = getProfileRecord(adapter.getUserHandle());
+ List<ChooserTarget> callerTargets = record == null
+ ? Collections.emptyList()
+ : record.callerTargets;
+ if (!callerTargets.isEmpty()) {
+ adapter.addServiceResults(
+ /* origTarget */ null,
+ new ArrayList<>(mRequest.getCallerChooserTargets()),
+ TARGET_TYPE_DEFAULT,
+ /* directShareShortcutInfoCache */ Collections.emptyMap(),
+ /* directShareAppTargetCache */ Collections.emptyMap());
}
}
@@ -2037,7 +2077,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
initialIntents,
rList,
filterLastUsed,
- createListController(userHandle),
+ resolverListController,
userHandle,
targetIntent,
referrerFillInIntent,
@@ -2052,8 +2092,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (record != null && record.shortcutLoader != null) {
record.shortcutLoader.reset();
}
- },
- mFeatureFlags);
+ });
}
private void onWorkProfileStatusUpdated() {
@@ -2108,11 +2147,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
mPinnedSharedPrefs);
}
- @VisibleForTesting
- protected ViewModelProvider.Factory createPreviewViewModelFactory() {
- return PreviewViewModel.Companion.getFactory();
- }
-
private ChooserContentPreviewUi.ActionFactory decorateActionFactoryWithRefinement(
ChooserContentPreviewUi.ActionFactory originalFactory) {
if (!mFeatureFlags.refineSystemActions()) {
@@ -2123,6 +2157,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
@Override
@Nullable
public Runnable getEditButtonRunnable() {
+ if (originalFactory.getEditButtonRunnable() == null) return null;
return () -> {
if (!mRefinementManager.maybeHandleSelection(
RefinementType.EDIT_ACTION,
@@ -2208,8 +2243,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
},
mShareResultSender,
this::finishWithStatus,
- mClipboardManager,
- mFeatureFlags);
+ mClipboardManager);
}
private Supplier<ActionRow.Action> createModifyShareActionFactory() {
@@ -2258,7 +2292,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (isLayoutUpdated
|| insetsChanged
- || mLastNumberOfChildren != recyclerView.getChildCount()) {
+ || mLastNumberOfChildren != recyclerView.getChildCount()
+ || mFeatureFlags.fixMissingDrawerOffsetCalculation()) {
mCurrAvailableWidth = availableWidth;
if (isLayoutUpdated) {
// It is very important we call setAdapter from here. Otherwise in some cases
@@ -2272,12 +2307,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile();
- int initialProfile = findSelectedProfile();
+ int initialProfile = Flags.fixDrawerOffsetOnConfigChange()
+ ? mInitialProfile
+ : findSelectedProfile();
if (currentProfile != initialProfile) {
return;
}
- if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) {
+ if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged
+ && !mFeatureFlags.fixMissingDrawerOffsetCalculation()) {
return;
}
@@ -2404,7 +2442,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
if (duration >= 0) {
Log.d(TAG, "app target loading time " + duration + " ms");
}
- addCallerChooserTargets();
+ if (!fixShortcutsFlashing()) {
+ addCallerChooserTargets(chooserListAdapter);
+ }
getEventLog().logSharesheetAppLoadComplete();
maybeQueryAdditionalPostProcessingTargets(
listProfileUserHandle,
@@ -2434,6 +2474,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
ChooserListAdapter adapter =
mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle);
if (adapter != null) {
+ if (fixShortcutsFlashing()) {
+ adapter.setDirectTargetsEnabled(true);
+ addCallerChooserTargets(adapter);
+ }
for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) {
adapter.addServiceResults(
resultInfo.getAppTarget(),
@@ -2675,6 +2719,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
}
private static class ProfileRecord {
+ public final Profile profile;
+
/** The {@link AppPredictor} for this profile, if any. */
@Nullable
public final AppPredictor appPredictor;
@@ -2683,19 +2729,27 @@ public class ChooserActivity extends Hilt_ChooserActivity implements
*/
@Nullable
public final ShortcutLoader shortcutLoader;
+ public final List<ChooserTarget> callerTargets;
public long loadingStartTime;
private ProfileRecord(
+ Profile profile,
@Nullable AppPredictor appPredictor,
- @Nullable ShortcutLoader shortcutLoader) {
+ @Nullable ShortcutLoader shortcutLoader,
+ List<ChooserTarget> callerTargets) {
+ this.profile = profile;
this.appPredictor = appPredictor;
this.shortcutLoader = shortcutLoader;
+ this.callerTargets = callerTargets;
}
public void destroy() {
if (appPredictor != null) {
appPredictor.destroy();
}
+ if (shortcutLoader != null) {
+ shortcutLoader.destroy();
+ }
}
}
}
diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt
index 312911a6..c26dd77c 100644
--- a/java/src/com/android/intentresolver/ChooserHelper.kt
+++ b/java/src/com/android/intentresolver/ChooserHelper.kt
@@ -27,7 +27,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
+import com.android.intentresolver.Flags.unselectFinalItem
import com.android.intentresolver.annotation.JavaInterop
+import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION
import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository
import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository
import com.android.intentresolver.data.model.ChooserRequest
@@ -39,6 +41,8 @@ import com.android.intentresolver.validation.log
import dagger.hilt.android.scopes.ActivityScoped
import java.util.function.Consumer
import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@@ -46,6 +50,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
private const val TAG: String = "ChooserHelper"
@@ -98,6 +103,7 @@ constructor(
var onChooserRequestChanged: Consumer<ChooserRequest> = Consumer {}
/** Invoked when there are a new change to payload selection */
var onPendingSelection: Runnable = Runnable {}
+ var onHasSelections: Consumer<Boolean> = Consumer {}
init {
activity.lifecycle.addObserver(this)
@@ -144,22 +150,39 @@ constructor(
}
activity.lifecycleScope.launch {
- val hasPendingCallbackFlow =
+ val hasPendingIntentFlow =
pendingSelectionCallbackRepo.pendingTargetIntent
.map { it != null }
.distinctUntilChanged()
- .onEach { hasPendingCallback ->
- if (hasPendingCallback) {
+ .onEach { hasPendingIntent ->
+ if (hasPendingIntent) {
onPendingSelection.run()
}
}
activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.request
- .combine(hasPendingCallbackFlow) { request, hasPendingCallback ->
- request to hasPendingCallback
+ val hasSelectionFlow =
+ if (
+ unselectFinalItem() &&
+ viewModel.previewDataProvider.previewType ==
+ CONTENT_PREVIEW_PAYLOAD_SELECTION
+ ) {
+ viewModel.shareouselViewModel.hasSelectedItems.stateIn(scope = this).also {
+ flow ->
+ launch { flow.collect { onHasSelections.accept(it) } }
+ }
+ } else {
+ MutableStateFlow(true).asStateFlow()
}
+ val requestControlFlow =
+ hasSelectionFlow
+ .combine(hasPendingIntentFlow) { hasSelections, hasPendingIntent ->
+ hasSelections && !hasPendingIntent
+ }
+ .distinctUntilChanged()
+ viewModel.request
+ .combine(requestControlFlow) { request, isReady -> request to isReady }
// only take ChooserRequest if there are no pending callbacks
- .filter { !it.second }
+ .filter { it.second }
.map { it.first }
.distinctUntilChanged(areEquivalent = { old, new -> old === new })
.collect { onChooserRequestChanged.accept(it) }
diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java
index ff0c40d7..016eb714 100644
--- a/java/src/com/android/intentresolver/ChooserListAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserListAdapter.java
@@ -111,7 +111,6 @@ 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<>();
@@ -154,6 +153,8 @@ public class ChooserListAdapter extends ResolverListAdapter {
};
private boolean mAnimateItems = true;
+ private boolean mTargetsEnabled = true;
+ private boolean mDirectTargetsEnabled = true;
public ChooserListAdapter(
Context context,
@@ -171,8 +172,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
int maxRankedTargets,
UserHandle initialIntentsUserSpace,
TargetDataLoader targetDataLoader,
- @Nullable PackageChangeCallback packageChangeCallback,
- FeatureFlags featureFlags) {
+ @Nullable PackageChangeCallback packageChangeCallback) {
this(
context,
payloadIntents,
@@ -191,8 +191,8 @@ public class ChooserListAdapter extends ResolverListAdapter {
targetDataLoader,
packageChangeCallback,
AsyncTask.SERIAL_EXECUTOR,
- context.getMainExecutor(),
- featureFlags);
+ context.getMainExecutor()
+ );
}
@VisibleForTesting
@@ -214,8 +214,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
TargetDataLoader targetDataLoader,
@Nullable PackageChangeCallback packageChangeCallback,
Executor bgExecutor,
- Executor mainExecutor,
- FeatureFlags featureFlags) {
+ Executor mainExecutor) {
// Don't send the initial intents through the shared ResolverActivity path,
// we want to separate them into a different section.
super(
@@ -239,7 +238,6 @@ public class ChooserListAdapter extends ResolverListAdapter {
mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context);
mTargetDataLoader = targetDataLoader;
mPackageChangeCallback = packageChangeCallback;
- mUseBadgeTextViewForLabels = featureFlags.bespokeLabelView();
createPlaceHolders();
mEventLog = eventLog;
mShortcutSelectionLogic = new ShortcutSelectionLogic(
@@ -310,6 +308,28 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
}
+ /**
+ * Set the enabled state for all targets.
+ */
+ public void setTargetsEnabled(boolean isEnabled) {
+ if (mTargetsEnabled != isEnabled) {
+ mTargetsEnabled = isEnabled;
+ notifyDataSetChanged();
+ }
+ }
+
+ /**
+ * Set the enabled state for direct targets.
+ */
+ public void setDirectTargetsEnabled(boolean isEnabled) {
+ if (mDirectTargetsEnabled != isEnabled) {
+ mDirectTargetsEnabled = isEnabled;
+ if (!mServiceTargets.isEmpty() && !isDirectTargetRowEmptyState()) {
+ notifyDataSetChanged();
+ }
+ }
+ }
+
public void setAnimateItems(boolean animateItems) {
mAnimateItems = animateItems;
}
@@ -345,12 +365,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
@Override
View onCreateView(ViewGroup parent) {
- return mInflater.inflate(
- mUseBadgeTextViewForLabels
- ? R.layout.chooser_grid_item
- : R.layout.resolve_grid_item,
- parent,
- false);
+ return mInflater.inflate(R.layout.chooser_grid_item, parent, false);
}
@Override
@@ -362,7 +377,8 @@ public class ChooserListAdapter extends ResolverListAdapter {
@VisibleForTesting
@Override
public void onBindView(View view, TargetInfo info, int position) {
- view.setEnabled(!isDestroyed());
+ final boolean isEnabled = !isDestroyed() && mTargetsEnabled;
+ view.setEnabled(isEnabled);
final ViewHolder holder = (ViewHolder) view.getTag();
resetViewHolder(holder);
@@ -387,6 +403,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
if (info.isSelectableTargetInfo()) {
+ view.setEnabled(isEnabled && mDirectTargetsEnabled);
// direct share targets should append the application name for a better readout
DisplayResolveInfo rInfo = info.getDisplayResolveInfo();
CharSequence appName =
@@ -421,7 +438,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
}
- holder.bindIcon(info);
+ holder.bindIcon(info, mTargetsEnabled);
if (mAnimateItems && info.hasDisplayIcon()) {
mAnimationTracker.animateIcon(holder.icon, info);
}
@@ -448,9 +465,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
holder.reset();
holder.itemView.setBackground(holder.defaultItemViewBackground);
- if (mUseBadgeTextViewForLabels) {
- ((BadgeTextView) holder.text).setBadgeDrawable(null);
- }
+ ((BadgeTextView) holder.text).setBadgeDrawable(null);
holder.text.setBackground(null);
holder.text.setPaddingRelative(0, 0, 0, 0);
}
@@ -464,12 +479,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
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);
- }
+ ((BadgeTextView) holder.text).setBadgeDrawable(indicator);
}
private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) {
@@ -748,7 +758,7 @@ public class ChooserListAdapter extends ResolverListAdapter {
Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos,
Map<ChooserTarget, AppTarget> directShareToAppTargets) {
// Avoid inserting any potentially late results.
- if ((mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo()) {
+ if (isDirectTargetRowEmptyState()) {
return;
}
boolean isShortcutResult = targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER
@@ -771,6 +781,22 @@ public class ChooserListAdapter extends ResolverListAdapter {
}
/**
+ * Copy direct targets from another ChooserListAdapter instance
+ */
+ public void copyDirectTargetsFrom(ChooserListAdapter adapter) {
+ if (adapter.isDirectTargetRowEmptyState()) {
+ return;
+ }
+
+ mServiceTargets.clear();
+ mServiceTargets.addAll(adapter.mServiceTargets);
+ }
+
+ private boolean isDirectTargetRowEmptyState() {
+ return (mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo();
+ }
+
+ /**
* Use the scoring system along with artificial boosts to create up to 4 distinct buckets:
* <ol>
* <li>App-supplied targets
diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java
deleted file mode 100644
index 06f56e3b..00000000
--- a/java/src/com/android/intentresolver/ChooserRequestParameters.java
+++ /dev/null
@@ -1,504 +0,0 @@
-/*
- * Copyright (C) 2008 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.intentresolver;
-
-
-import android.content.ComponentName;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.IntentSender;
-import android.net.Uri;
-import android.os.Bundle;
-import android.os.Parcelable;
-import android.os.PatternMatcher;
-import android.service.chooser.ChooserAction;
-import android.service.chooser.ChooserTarget;
-import android.text.TextUtils;
-import android.util.Log;
-import android.util.Pair;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.android.intentresolver.util.UriFilters;
-
-import com.google.common.collect.ImmutableList;
-
-import java.net.URISyntaxException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Optional;
-import java.util.stream.Collector;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-/**
- * Utility to parse and validate parameters from the client-supplied {@link Intent} that launched
- * the Sharesheet {@link ChooserActivity}. The validated parameters are stored as immutable ivars.
- *
- * TODO: field nullability in this class reflects legacy use, and typically would indicate that the
- * client's intent didn't provide the respective data. In some cases we may be able to provide
- * defaults instead of nulls -- especially for methods that return nullable lists or arrays, if the
- * client code could instead handle empty collections equally well.
- *
- * TODO: some of these fields (especially getTargetIntent() and any other getters that delegate to
- * it internally) differ from the legacy model because they're computed directly from the initial
- * Chooser intent, where in the past they've been relayed up to ResolverActivity and then retrieved
- * through methods on the base class. The base always seems to return them exactly as they were
- * provided, so this should be safe -- and clients can reasonably switch to retrieving through these
- * parameters instead. For now, the other convention is still used in some places. Ideally we'd like
- * to normalize on a single source of truth, but we'll have to clean up the delegation up to the
- * resolver (or perhaps this needs to be a subclass of some `ResolverRequestParameters` class?).
- */
-public class ChooserRequestParameters {
- private static final String TAG = "ChooserActivity";
-
- private static final int LAUNCH_FLAGS_FOR_SEND_ACTION =
- Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
- private static final int MAX_CHOOSER_ACTIONS = 5;
-
- private final Intent mTarget;
- private final String mReferrerPackageName;
- private final Pair<CharSequence, Integer> mTitleSpec;
- private final Intent mReferrerFillInIntent;
- private final ImmutableList<ComponentName> mFilteredComponentNames;
- private final ImmutableList<ChooserTarget> mCallerChooserTargets;
- private final @NonNull ImmutableList<ChooserAction> mChooserActions;
- private final ChooserAction mModifyShareAction;
- private final boolean mRetainInOnStop;
-
- @Nullable
- private final ImmutableList<Intent> mAdditionalTargets;
-
- @Nullable
- private final Bundle mReplacementExtras;
-
- @Nullable
- private final ImmutableList<Intent> mInitialIntents;
-
- @Nullable
- private final IntentSender mChosenComponentSender;
-
- @Nullable
- private final IntentSender mRefinementIntentSender;
-
- @Nullable
- private final String mSharedText;
-
- @Nullable
- private final IntentFilter mTargetIntentFilter;
-
- @Nullable
- private final CharSequence mMetadataText;
-
- public ChooserRequestParameters(
- final Intent clientIntent,
- String referrerPackageName,
- final Uri referrer) {
- final Intent requestedTarget = parseTargetIntentExtra(
- clientIntent.getParcelableExtra(Intent.EXTRA_INTENT));
- mTarget = intentWithModifiedLaunchFlags(requestedTarget);
-
- mReferrerPackageName = referrerPackageName;
-
- mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent(
- clientIntent, Intent.EXTRA_ALTERNATE_INTENTS);
-
- mReplacementExtras = clientIntent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS);
-
- mTitleSpec = makeTitleSpec(
- clientIntent.getCharSequenceExtra(Intent.EXTRA_TITLE),
- isSendAction(mTarget.getAction()));
-
- mInitialIntents = intentsWithModifiedLaunchFlagsFromExtraIfPresent(
- clientIntent, Intent.EXTRA_INITIAL_INTENTS);
-
- mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer);
-
- mChosenComponentSender =
- 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);
-
- ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra(
- Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class);
- mFilteredComponentNames = filteredComponents != null
- ? ImmutableList.copyOf(filteredComponents)
- : ImmutableList.of();
-
- mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent);
-
- mRetainInOnStop = clientIntent.getBooleanExtra(
- ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false);
-
- mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT);
-
- mTargetIntentFilter = getTargetIntentFilter(mTarget);
-
- 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() {
- return mTarget;
- }
-
- @Nullable
- public String getTargetAction() {
- return getTargetIntent().getAction();
- }
-
- public boolean isSendActionTarget() {
- return isSendAction(getTargetAction());
- }
-
- @Nullable
- public String getTargetType() {
- return getTargetIntent().getType();
- }
-
- public String getReferrerPackageName() {
- return mReferrerPackageName;
- }
-
- @Nullable
- public CharSequence getTitle() {
- return mTitleSpec.first;
- }
-
- public int getDefaultTitleResource() {
- return mTitleSpec.second;
- }
-
- public Intent getReferrerFillInIntent() {
- return mReferrerFillInIntent;
- }
-
- public ImmutableList<ComponentName> getFilteredComponentNames() {
- return mFilteredComponentNames;
- }
-
- public ImmutableList<ChooserTarget> getCallerChooserTargets() {
- return mCallerChooserTargets;
- }
-
- @NonNull
- public ImmutableList<ChooserAction> getChooserActions() {
- return mChooserActions;
- }
-
- @Nullable
- public ChooserAction getModifyShareAction() {
- return mModifyShareAction;
- }
-
- /**
- * Whether the {@link ChooserActivity#EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested.
- */
- public boolean shouldRetainInOnStop() {
- return mRetainInOnStop;
- }
-
- /**
- * TODO: this returns a nullable array for convenience, but if the legacy APIs can be
- * refactored, returning {@link #mAdditionalTargets} directly is simpler and safer.
- */
- @Nullable
- public Intent[] getAdditionalTargets() {
- return (mAdditionalTargets == null) ? null : mAdditionalTargets.toArray(new Intent[0]);
- }
-
- @Nullable
- public Bundle getReplacementExtras() {
- return mReplacementExtras;
- }
-
- /**
- * TODO: this returns a nullable array for convenience, but if the legacy APIs can be
- * refactored, returning {@link #mInitialIntents} directly is simpler and safer.
- */
- @Nullable
- public Intent[] getInitialIntents() {
- return (mInitialIntents == null) ? null : mInitialIntents.toArray(new Intent[0]);
- }
-
- @Nullable
- public IntentSender getChosenComponentSender() {
- return mChosenComponentSender;
- }
-
- @Nullable
- public IntentSender getRefinementIntentSender() {
- return mRefinementIntentSender;
- }
-
- @Nullable
- public String getSharedText() {
- return mSharedText;
- }
-
- @Nullable
- public IntentFilter getTargetIntentFilter() {
- return mTargetIntentFilter;
- }
-
- @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));
- }
-
- private static Intent parseTargetIntentExtra(@Nullable Parcelable targetParcelable) {
- if (targetParcelable instanceof Uri) {
- try {
- targetParcelable = Intent.parseUri(targetParcelable.toString(),
- Intent.URI_INTENT_SCHEME);
- } catch (URISyntaxException ex) {
- throw new IllegalArgumentException("Failed to parse EXTRA_INTENT from URI", ex);
- }
- }
-
- if (!(targetParcelable instanceof Intent)) {
- throw new IllegalArgumentException(
- "EXTRA_INTENT is neither an Intent nor a Uri: " + targetParcelable);
- }
-
- return ((Intent) targetParcelable);
- }
-
- private static Intent intentWithModifiedLaunchFlags(Intent intent) {
- if (isSendAction(intent.getAction())) {
- intent.addFlags(LAUNCH_FLAGS_FOR_SEND_ACTION);
- }
- return intent;
- }
-
- /**
- * Build a pair of values specifying the title to use from the client request. The first
- * ({@link CharSequence}) value is the client-specified title, if there was one and their
- * requested target <em>wasn't</em> a send action; otherwise it is null. The second value is
- * the resource ID of a default title string; this is nonzero only if the first value is null.
- *
- * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate}, or
- * create a real type (not {@link Pair}) to express the semantics described in this comment.
- */
- private static Pair<CharSequence, Integer> makeTitleSpec(
- @Nullable CharSequence requestedTitle, boolean hasSendActionTarget) {
- if (hasSendActionTarget && (requestedTitle != null)) {
- // Do not allow the title to be changed when sharing content
- Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a"
- + " preview title by using EXTRA_TITLE property of the wrapped"
- + " EXTRA_INTENT.");
- requestedTitle = null;
- }
-
- int defaultTitleRes = (requestedTitle == null) ? R.string.chooseActivity : 0;
-
- return Pair.create(requestedTitle, defaultTitleRes);
- }
-
- private static ImmutableList<ChooserTarget> parseCallerTargetsFromClientIntent(
- Intent clientIntent) {
- return
- streamParcelableArrayExtra(
- clientIntent, Intent.EXTRA_CHOOSER_TARGETS, ChooserTarget.class, true, true)
- .collect(toImmutableList());
- }
-
- @NonNull
- private static ImmutableList<ChooserAction> getChooserActions(Intent intent) {
- return streamParcelableArrayExtra(
- intent,
- Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS,
- ChooserAction.class,
- true,
- true)
- .filter(UriFilters::hasValidIcon)
- .limit(MAX_CHOOSER_ACTIONS)
- .collect(toImmutableList());
- }
-
- @Nullable
- private static ChooserAction getModifyShareAction(Intent intent) {
- try {
- return intent.getParcelableExtra(
- Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION,
- ChooserAction.class);
- } catch (Throwable t) {
- Log.w(
- TAG,
- "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION argument",
- t);
- return null;
- }
- }
-
- private static <T> Collector<T, ?, ImmutableList<T>> toImmutableList() {
- return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf);
- }
-
- @Nullable
- private static ImmutableList<Intent> intentsWithModifiedLaunchFlagsFromExtraIfPresent(
- Intent clientIntent, String extra) {
- Stream<Intent> intents =
- streamParcelableArrayExtra(clientIntent, extra, Intent.class, true, false);
- if (intents == null) {
- return null;
- }
- return intents
- .map(ChooserRequestParameters::intentWithModifiedLaunchFlags)
- .collect(toImmutableList());
- }
-
- /**
- * Make a {@link Stream} of the {@link Parcelable} objects given in the provided {@link Intent}
- * as the optional parcelable array extra with key {@code extra}. The stream elements, if any,
- * are all of the type specified by {@code clazz}.
- *
- * @param intent The intent that may contain the optional extras.
- * @param extra The extras key to identify the parcelable array.
- * @param clazz A class that is assignable from any elements in the result stream.
- * @param warnOnTypeError Whether to log a warning (and ignore) if the client extra doesn't have
- * the required type. If false, throw an {@link IllegalArgumentException} if the extra is
- * non-null but can't be assigned to variables of type {@code T}.
- * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't
- * present in the intent (or if it had the wrong type, but <em>warnOnTypeError</em> is true).
- * If false, return null in these cases, and only return an empty stream if the intent
- * explicitly provided an empty array for the specified extra.
- */
- @Nullable
- private static <T extends Parcelable> Stream<T> streamParcelableArrayExtra(
- final Intent intent,
- String extra,
- @NonNull Class<T> clazz,
- boolean warnOnTypeError,
- boolean streamEmptyIfNull) {
- T[] result = null;
-
- try {
- result = getParcelableArrayExtraIfPresent(intent, extra, clazz);
- } catch (IllegalArgumentException e) {
- if (warnOnTypeError) {
- Log.w(TAG, "Ignoring client-requested " + extra, e);
- } else {
- throw e;
- }
- }
-
- if (result != null) {
- return Arrays.stream(result);
- } else if (streamEmptyIfNull) {
- return Stream.empty();
- } else {
- return null;
- }
- }
-
- /**
- * If the specified {@code extra} is provided in the {@code intent}, cast it to type {@code T[]}
- * or throw an {@code IllegalArgumentException} if the cast fails. If the {@code extra} isn't
- * present in the {@code intent}, return null.
- */
- @Nullable
- private static <T extends Parcelable> T[] getParcelableArrayExtraIfPresent(
- final Intent intent, String extra, @NonNull Class<T> clazz) throws
- IllegalArgumentException {
- if (!intent.hasExtra(extra)) {
- return null;
- }
-
- T[] castResult = intent.getParcelableArrayExtra(extra, clazz);
- if (castResult == null) {
- Parcelable[] actualExtrasArray = intent.getParcelableArrayExtra(extra);
- if (actualExtrasArray != null) {
- throw new IllegalArgumentException(
- String.format(
- "%s is not of type %s[]: %s",
- extra,
- clazz.getSimpleName(),
- Arrays.toString(actualExtrasArray)));
- } else if (intent.getParcelableExtra(extra) != null) {
- throw new IllegalArgumentException(
- String.format(
- "%s is not of type %s[] (or any array type): %s",
- extra,
- clazz.getSimpleName(),
- intent.getParcelableExtra(extra)));
- } else {
- throw new IllegalArgumentException(
- String.format(
- "%s is not of type %s (or any Parcelable type): %s",
- extra,
- clazz.getSimpleName(),
- intent.getExtras().get(extra)));
- }
- }
-
- return castResult;
- }
-
- private static IntentFilter getTargetIntentFilter(final Intent intent) {
- try {
- String dataString = intent.getDataString();
- if (intent.getType() == null) {
- if (!TextUtils.isEmpty(dataString)) {
- return new IntentFilter(intent.getAction(), dataString);
- }
- Log.e(TAG, "Failed to get target intent filter: intent data and type are null");
- return null;
- }
- IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType());
- List<Uri> contentUris = new ArrayList<>();
- if (Intent.ACTION_SEND.equals(intent.getAction())) {
- Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
- if (uri != null) {
- contentUris.add(uri);
- }
- } else {
- List<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
- if (uris != null) {
- contentUris.addAll(uris);
- }
- }
- for (Uri uri : contentUris) {
- intentFilter.addDataScheme(uri.getScheme());
- intentFilter.addDataAuthority(uri.getAuthority(), null);
- intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL);
- }
- return intentFilter;
- } catch (Exception e) {
- Log.e(TAG, "Failed to get target intent filter", e);
- return null;
- }
- }
-}
diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java
index 5fd37d43..fc5514b6 100644
--- a/java/src/com/android/intentresolver/ResolverListAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverListAdapter.java
@@ -16,14 +16,15 @@
package com.android.intentresolver;
+import static com.android.intentresolver.Flags.unselectFinalItem;
+import static com.android.intentresolver.util.graphics.SuspendedMatrixColorFilter.getSuspendedColorMatrix;
+
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.LabeledIntent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
-import android.graphics.ColorMatrix;
-import android.graphics.ColorMatrixColorFilter;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.RemoteException;
@@ -63,9 +64,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
public class ResolverListAdapter extends BaseAdapter {
private static final String TAG = "ResolverListAdapter";
- @Nullable // TODO: other model for lazy computation? Or just precompute?
- private static ColorMatrixColorFilter sSuspendedMatrixColorFilter;
-
protected final Context mContext;
protected final LayoutInflater mInflater;
protected final ResolverListCommunicator mResolverListCommunicator;
@@ -797,29 +795,6 @@ public class ResolverListAdapter extends BaseAdapter {
return mDestroyed.get();
}
- private static ColorMatrixColorFilter getSuspendedColorMatrix() {
- if (sSuspendedMatrixColorFilter == null) {
-
- int grayValue = 127;
- float scale = 0.5f; // half bright
-
- ColorMatrix tempBrightnessMatrix = new ColorMatrix();
- float[] mat = tempBrightnessMatrix.getArray();
- mat[0] = scale;
- mat[6] = scale;
- mat[12] = scale;
- mat[4] = grayValue;
- mat[9] = grayValue;
- mat[14] = grayValue;
-
- ColorMatrix matrix = new ColorMatrix();
- matrix.setSaturation(0.0f);
- matrix.preConcat(tempBrightnessMatrix);
- sSuspendedMatrixColorFilter = new ColorMatrixColorFilter(matrix);
- }
- return sSuspendedMatrixColorFilter;
- }
-
protected final Drawable loadIconPlaceholder() {
return mContext.getDrawable(R.drawable.resolver_icon_placeholder);
}
@@ -999,13 +974,26 @@ public class ResolverListAdapter extends BaseAdapter {
/**
* Bind view holder to a TargetInfo.
*/
- public void bindIcon(TargetInfo info) {
+ public final void bindIcon(TargetInfo info) {
+ bindIcon(info, true);
+ }
+
+ /**
+ * Bind view holder to a TargetInfo.
+ */
+ public void bindIcon(TargetInfo info, boolean isEnabled) {
Drawable displayIcon = info.getDisplayIconHolder().getDisplayIcon();
icon.setImageDrawable(displayIcon);
- if (info.isSuspended()) {
+ if (info.isSuspended() || !isEnabled) {
icon.setColorFilter(getSuspendedColorMatrix());
} else {
icon.setColorFilter(null);
+ if (unselectFinalItem() && displayIcon != null) {
+ // For some reason, ImageView.setColorFilter() not always propagate the call
+ // to the drawable and the icon remains grayscale when rebound; reset the filter
+ // explicitly.
+ displayIcon.setColorFilter(null);
+ }
}
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
deleted file mode 100644
index dc36e584..00000000
--- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt
+++ /dev/null
@@ -1,35 +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.contentpreview
-
-import android.content.Intent
-import android.net.Uri
-import androidx.annotation.MainThread
-import androidx.lifecycle.ViewModel
-
-/** A contract for the preview view model. Added for testing. */
-abstract class BasePreviewViewModel : ViewModel() {
- @get:MainThread abstract val previewDataProvider: PreviewDataProvider
- @get:MainThread abstract val imageLoader: ImageLoader
-
- @MainThread
- abstract fun init(
- targetIntent: Intent,
- additionalContentUri: Uri?,
- isPayloadTogglingEnabled: Boolean,
- )
-}
diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt
index 2e2aa938..847fcc82 100644
--- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt
@@ -19,10 +19,10 @@ package com.android.intentresolver.contentpreview
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
+import android.util.Size
import androidx.core.util.lruCache
import com.android.intentresolver.inject.Background
import com.android.intentresolver.inject.ViewModelOwned
-import java.util.function.Consumer
import javax.inject.Inject
import javax.inject.Qualifier
import kotlinx.coroutines.CoroutineDispatcher
@@ -31,7 +31,6 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
@@ -74,15 +73,11 @@ constructor(
}
)
- override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) {
- callerScope.launch { callback.accept(loadCachedImage(uri)) }
+ override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) {
+ uriSizePairs.take(cache.maxSize()).map { cache[it.first] }
}
- override fun prePopulate(uris: List<Uri>) {
- uris.take(cache.maxSize()).map { cache[it] }
- }
-
- override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? {
+ override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? {
return if (caching) {
loadCachedImage(uri)
} else {
@@ -92,7 +87,7 @@ constructor(
private suspend fun loadUncachedImage(uri: Uri): Bitmap? =
withContext(bgDispatcher) {
- runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } }
+ runCatching { semaphore.withPermit { thumbnailLoader.loadThumbnail(uri) } }
.onFailure {
ensureActive()
Log.d(TAG, "Failed to load preview for $uri", it)
diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
index 4b955c49..1128ec5d 100644
--- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
@@ -22,7 +22,6 @@ import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTE
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT;
import android.content.ClipData;
-import android.content.Intent;
import android.content.res.Resources;
import android.net.Uri;
import android.text.TextUtils;
@@ -34,6 +33,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.intentresolver.ContentTypeHint;
+import com.android.intentresolver.data.model.ChooserRequest;
import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback;
@@ -102,7 +102,7 @@ public final class ChooserContentPreviewUi {
public ChooserContentPreviewUi(
CoroutineScope scope,
PreviewDataProvider previewData,
- Intent targetIntent,
+ ChooserRequest chooserRequest,
ImageLoader imageLoader,
ActionFactory actionFactory,
Supplier</*@Nullable*/ActionRow.Action> modifyShareActionFactory,
@@ -117,7 +117,7 @@ public final class ChooserContentPreviewUi {
mModifyShareActionFactory = modifyShareActionFactory;
mContentPreviewUi = createContentPreview(
previewData,
- targetIntent,
+ chooserRequest,
DefaultMimeTypeClassifier.INSTANCE,
imageLoader,
actionFactory,
@@ -133,7 +133,7 @@ public final class ChooserContentPreviewUi {
private ContentPreviewUi createContentPreview(
PreviewDataProvider previewData,
- Intent targetIntent,
+ ChooserRequest chooserRequest,
MimeTypeClassifier typeClassifier,
ImageLoader imageLoader,
ActionFactory actionFactory,
@@ -146,7 +146,9 @@ public final class ChooserContentPreviewUi {
if (previewType == CONTENT_PREVIEW_TEXT) {
return createTextPreview(
mScope,
- targetIntent,
+ chooserRequest.getTargetIntent().getClipData(),
+ chooserRequest.getSharedText(),
+ chooserRequest.getSharedTextTitle(),
actionFactory,
imageLoader,
headlineGenerator,
@@ -174,15 +176,14 @@ public final class ChooserContentPreviewUi {
boolean isSingleImageShare = previewData.getUriCount() == 1
&& typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType());
- CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT);
- if (!TextUtils.isEmpty(text)) {
+ if (!TextUtils.isEmpty(chooserRequest.getSharedText())) {
FilesPlusTextContentPreviewUi previewUi =
new FilesPlusTextContentPreviewUi(
mScope,
isSingleImageShare,
previewData.getUriCount(),
- targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT),
- targetIntent.getType(),
+ chooserRequest.getSharedText(),
+ chooserRequest.getTargetType(),
actionFactory,
imageLoader,
typeClassifier,
@@ -201,7 +202,7 @@ public final class ChooserContentPreviewUi {
return new UnifiedContentPreviewUi(
mScope,
isSingleImageShare,
- targetIntent.getType(),
+ chooserRequest.getTargetType(),
actionFactory,
imageLoader,
typeClassifier,
@@ -243,16 +244,15 @@ public final class ChooserContentPreviewUi {
private static TextContentPreviewUi createTextPreview(
CoroutineScope scope,
- Intent targetIntent,
+ ClipData previewData,
+ @Nullable CharSequence sharingText,
+ @Nullable CharSequence previewTitle,
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
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();
Uri previewThumbnail = null;
if (previewData != null) {
if (previewData.getItemCount() > 0) {
diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
index b50f5bc8..30161cfb 100644
--- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java
@@ -23,6 +23,7 @@ import android.content.res.Resources;
import android.net.Uri;
import android.text.util.Linkify;
import android.util.PluralsMessageFormatter;
+import android.util.Size;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -68,6 +69,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
private Uri mFirstFilePreviewUri;
private boolean mAllImages;
private boolean mAllVideos;
+ private int mPreviewSize;
// TODO(b/285309527): make this a flag
private static final boolean SHOW_TOGGLE_CHECKMARK = false;
@@ -109,6 +111,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
LayoutInflater layoutInflater,
ViewGroup parent,
View headlineViewParent) {
+ mPreviewSize = resources.getDimensionPixelSize(R.dimen.width_text_image_preview_size);
return displayInternal(layoutInflater, parent, headlineViewParent);
}
@@ -164,12 +167,12 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi {
private void updateUiWithMetadata(ViewGroup contentPreviewView, View headlineView) {
prepareTextPreview(contentPreviewView, headlineView, mActionFactory);
updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos);
-
ImageView imagePreview = mContentPreviewView.requireViewById(R.id.image_view);
if (mIsSingleImage && mFirstFilePreviewUri != null) {
mImageLoader.loadImage(
mScope,
mFirstFilePreviewUri,
+ new Size(mPreviewSize, mPreviewSize),
bitmap -> {
if (bitmap == null) {
imagePreview.setVisibility(View.GONE);
diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt
index 21308341..059ee083 100644
--- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt
+++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt
@@ -36,4 +36,6 @@ interface HeadlineGenerator {
fun getVideosHeadline(count: Int): String
fun getFilesHeadline(count: Int): String
+
+ fun getNotItemsSelectedHeadline(): String
}
diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
index e92d9bc6..822d3097 100644
--- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
+++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt
@@ -93,6 +93,9 @@ constructor(
return getPluralString(R.string.sharing_files, count)
}
+ override fun getNotItemsSelectedHeadline(): String =
+ context.getString(R.string.select_items_to_share)
+
private fun getPluralString(@StringRes templateResource: Int, count: Int): String {
return PluralsMessageFormatter.format(
context.resources,
diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt
index 81913a8e..ac34f552 100644
--- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt
@@ -18,28 +18,39 @@ package com.android.intentresolver.contentpreview
import android.graphics.Bitmap
import android.net.Uri
+import android.util.Size
import java.util.function.Consumer
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
/** A content preview image loader. */
-interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? {
+interface ImageLoader : suspend (Uri, Size) -> Bitmap?, suspend (Uri, Size, Boolean) -> Bitmap? {
/**
* Load preview image asynchronously; caching is allowed.
*
* @param uri content URI
+ * @param size target bitmap size
* @param callback a callback that will be invoked with the loaded image or null if loading has
* failed.
*/
- fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>)
+ fun loadImage(callerScope: CoroutineScope, uri: Uri, size: Size, callback: Consumer<Bitmap?>) {
+ callerScope.launch {
+ val bitmap = invoke(uri, size)
+ if (isActive) {
+ callback.accept(bitmap)
+ }
+ }
+ }
/** Prepopulate the image loader cache. */
- fun prePopulate(uris: List<Uri>)
+ fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>)
/** Returns a bitmap for the given URI if it's already cached, otherwise null */
fun getCachedBitmap(uri: Uri): Bitmap? = null
/** Load preview image; caching is allowed. */
- override suspend fun invoke(uri: Uri) = invoke(uri, true)
+ override suspend fun invoke(uri: Uri, size: Size) = invoke(uri, size, true)
/**
* Load preview image.
@@ -47,5 +58,5 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm
* @param uri content URI
* @param caching indicates if the loaded image could be cached.
*/
- override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap?
+ override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap?
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
index 7035f765..27e817db 100644
--- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt
@@ -17,28 +17,34 @@
package com.android.intentresolver.contentpreview
import android.content.res.Resources
+import com.android.intentresolver.Flags
import com.android.intentresolver.R
import com.android.intentresolver.inject.ApplicationOwned
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
-import dagger.hilt.android.components.ActivityRetainedComponent
-import dagger.hilt.android.scopes.ActivityRetainedScoped
+import dagger.hilt.android.components.ViewModelComponent
+import javax.inject.Provider
@Module
-@InstallIn(ActivityRetainedComponent::class)
+@InstallIn(ViewModelComponent::class)
interface ImageLoaderModule {
- @Binds
- @ActivityRetainedScoped
- fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader
-
- @Binds
- @ActivityRetainedScoped
- fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader
+ @Binds fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader
companion object {
@Provides
+ fun imageLoader(
+ imagePreviewImageLoader: Provider<ImagePreviewImageLoader>,
+ previewImageLoader: Provider<PreviewImageLoader>
+ ): ImageLoader =
+ if (Flags.previewImageLoader()) {
+ previewImageLoader.get()
+ } else {
+ imagePreviewImageLoader.get()
+ }
+
+ @Provides
@ThumbnailSize
fun thumbnailSize(@ApplicationOwned resources: Resources): Int =
resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen)
diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
index fab7203e..379bdb37 100644
--- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt
@@ -25,7 +25,6 @@ import androidx.annotation.GuardedBy
import androidx.annotation.VisibleForTesting
import androidx.collection.LruCache
import com.android.intentresolver.inject.Background
-import java.util.function.Consumer
import javax.inject.Inject
import javax.inject.Qualifier
import kotlinx.coroutines.CancellationException
@@ -36,7 +35,6 @@ import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
@@ -100,19 +98,11 @@ constructor(
@GuardedBy("lock") private val cache = LruCache<Uri, RequestRecord>(cacheSize)
@GuardedBy("lock") private val runningRequests = HashMap<Uri, RequestRecord>()
- override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching)
+ override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? =
+ loadImageAsync(uri, caching)
- override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) {
- callerScope.launch {
- val image = loadImageAsync(uri, caching = true)
- if (isActive) {
- callback.accept(image)
- }
- }
- }
-
- override fun prePopulate(uris: List<Uri>) {
- uris.asSequence().take(cache.maxSize()).forEach { uri ->
+ override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) {
+ uriSizePairs.asSequence().take(cache.maxSize()).forEach { (uri, _) ->
scope.launch { loadImageAsync(uri, caching = true) }
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
index 96bb8258..9b2dbebf 100644
--- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
@@ -32,6 +32,7 @@ import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREV
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.inject.ChooserServiceFlags
import com.android.intentresolver.measurements.runTracing
import com.android.intentresolver.util.ownedByCurrentUser
import java.util.concurrent.atomic.AtomicInteger
@@ -76,9 +77,7 @@ constructor(
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 featureFlags: ChooserServiceFlags,
private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier,
) {
@@ -129,7 +128,7 @@ constructor(
* IMAGE, FILE, TEXT. */
if (!targetIntent.isSend || records.isEmpty()) {
CONTENT_PREVIEW_TEXT
- } else if (isPayloadTogglingEnabled && shouldShowPayloadSelection()) {
+ } else if (featureFlags.chooserPayloadToggling() && shouldShowPayloadSelection()) {
// TODO: replace with the proper flags injection
CONTENT_PREVIEW_PAYLOAD_SELECTION
} else {
@@ -275,13 +274,16 @@ constructor(
val mimeType: String? by lazy { contentResolver.getTypeSafe(uri) }
val isImageType: Boolean
get() = typeClassifier.isImageType(mimeType)
+
val supportsImageType: Boolean by lazy {
contentResolver.getStreamTypesSafe(uri).firstOrNull(typeClassifier::isImageType) != null
}
val supportsThumbnail: Boolean
get() = query.supportsThumbnail
+
val title: String
get() = query.title
+
val iconUri: Uri?
get() = query.iconUri
@@ -326,8 +328,7 @@ constructor(
}
QueryResult(supportsThumbnail, title, iconUri)
- }
- ?: QueryResult()
+ } ?: QueryResult()
}
private class QueryResult(
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt
new file mode 100644
index 00000000..b10f7ef9
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt
@@ -0,0 +1,197 @@
+/*
+ * 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.graphics.Bitmap
+import android.net.Uri
+import android.util.Log
+import android.util.Size
+import androidx.collection.lruCache
+import com.android.intentresolver.inject.Background
+import com.android.intentresolver.inject.ViewModelOwned
+import javax.annotation.concurrent.GuardedBy
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+
+private const val TAG = "PayloadSelImageLoader"
+
+/**
+ * Implements preview image loading for the payload selection UI. Cancels preview loading for items
+ * that has been evicted from the cache at the expense of a possible request duplication (deemed
+ * unlikely).
+ */
+class PreviewImageLoader
+@Inject
+constructor(
+ @ViewModelOwned private val scope: CoroutineScope,
+ @PreviewCacheSize private val cacheSize: Int,
+ @ThumbnailSize private val defaultPreviewSize: Int,
+ private val thumbnailLoader: ThumbnailLoader,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ @PreviewMaxConcurrency maxSimultaneousRequests: Int = 4,
+) : ImageLoader {
+
+ private val contentResolverSemaphore = Semaphore(maxSimultaneousRequests)
+
+ private val lock = Any()
+ @GuardedBy("lock") private val runningRequests = hashMapOf<Uri, RequestRecord>()
+ @GuardedBy("lock")
+ private val cache =
+ lruCache<Uri, RequestRecord>(
+ maxSize = cacheSize,
+ onEntryRemoved = { _, _, oldRec, newRec ->
+ if (oldRec !== newRec) {
+ onRecordEvictedFromCache(oldRec)
+ }
+ }
+ )
+
+ override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? =
+ loadImageInternal(uri, size, caching)
+
+ override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) {
+ uriSizePairs.asSequence().take(cacheSize).forEach { uri ->
+ scope.launch { loadImageInternal(uri.first, uri.second, caching = true) }
+ }
+ }
+
+ private suspend fun loadImageInternal(uri: Uri, size: Size, caching: Boolean): Bitmap? {
+ return withRequestRecord(uri, caching) { record ->
+ val newSize = sanitize(size)
+ val newMetric = newSize.metric
+ record
+ .also {
+ // set the requested size to the max of the new and the previous value; input
+ // will emit if the resulted value is greater than the old one
+ it.input.update { oldSize ->
+ if (oldSize == null || oldSize.metric < newSize.metric) newSize else oldSize
+ }
+ }
+ .output
+ // filter out bitmaps of a lower resolution than that we're requesting
+ .filter { it is BitmapLoadingState.Loaded && newMetric <= it.size.metric }
+ .firstOrNull()
+ ?.let { (it as BitmapLoadingState.Loaded).bitmap }
+ }
+ }
+
+ private suspend fun withRequestRecord(
+ uri: Uri,
+ caching: Boolean,
+ block: suspend (RequestRecord) -> Bitmap?
+ ): Bitmap? {
+ val record = trackRecordRunning(uri, caching)
+ return try {
+ block(record)
+ } finally {
+ untrackRecordRunning(uri, record)
+ }
+ }
+
+ private fun trackRecordRunning(uri: Uri, caching: Boolean): RequestRecord =
+ synchronized(lock) {
+ runningRequests
+ .getOrPut(uri) { cache[uri] ?: createRecord(uri) }
+ .also { record ->
+ record.clientCount++
+ if (caching) {
+ cache.put(uri, record)
+ }
+ }
+ }
+
+ private fun untrackRecordRunning(uri: Uri, record: RequestRecord) {
+ synchronized(lock) {
+ record.clientCount--
+ if (record.clientCount <= 0) {
+ runningRequests.remove(uri)
+ val result = record.output.value
+ if (cache[uri] == null) {
+ record.loadingJob.cancel()
+ } else if (result is BitmapLoadingState.Loaded && result.bitmap == null) {
+ cache.remove(uri)
+ }
+ }
+ }
+ }
+
+ private fun onRecordEvictedFromCache(record: RequestRecord) {
+ synchronized(lock) {
+ if (record.clientCount <= 0) {
+ record.loadingJob.cancel()
+ }
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun createRecord(uri: Uri): RequestRecord {
+ // use a StateFlow with sentinel values to avoid using SharedFlow that is deemed dangerous
+ val input = MutableStateFlow<Size?>(null)
+ val output = MutableStateFlow<BitmapLoadingState>(BitmapLoadingState.Loading)
+ val job =
+ scope.launch(bgDispatcher) {
+ // the image loading pipeline: input -- a desired image size, output -- a bitmap
+ input
+ .filterNotNull()
+ .mapLatest { size -> BitmapLoadingState.Loaded(size, loadBitmap(uri, size)) }
+ .collect { output.tryEmit(it) }
+ }
+ return RequestRecord(input, output, job, clientCount = 0)
+ }
+
+ private suspend fun loadBitmap(uri: Uri, size: Size): Bitmap? =
+ contentResolverSemaphore.withPermit {
+ runCatching { thumbnailLoader.loadThumbnail(uri, size) }
+ .onFailure { Log.d(TAG, "failed to load $uri preview", it) }
+ .getOrNull()
+ }
+
+ private class RequestRecord(
+ /** The image loading pipeline input: desired preview size */
+ val input: MutableStateFlow<Size?>,
+ /** The image loading pipeline output */
+ val output: MutableStateFlow<BitmapLoadingState>,
+ /** The image loading pipeline job */
+ val loadingJob: Job,
+ @GuardedBy("lock") var clientCount: Int,
+ )
+
+ private sealed interface BitmapLoadingState {
+ data object Loading : BitmapLoadingState
+
+ data class Loaded(val size: Size, val bitmap: Bitmap?) : BitmapLoadingState
+ }
+
+ private fun sanitize(size: Size?): Size =
+ size?.takeIf { it.width > 0 && it.height > 0 }
+ ?: Size(defaultPreviewSize, defaultPreviewSize)
+}
+
+private val Size.metric
+ get() = maxOf(width, height)
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
deleted file mode 100644
index 6a729945..00000000
--- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
+++ /dev/null
@@ -1,98 +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.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.R
-import com.android.intentresolver.inject.Background
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.plus
-
-/** 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 targetIntent: Intent? = null
- private var additionalContentUri: Uri? = null
- private var isPayloadTogglingEnabled = false
-
- 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
- )
- }
-
- // TODO: make the view model injectable and inject these dependencies instead
- @MainThread
- override fun init(
- targetIntent: Intent,
- additionalContentUri: Uri?,
- isPayloadTogglingEnabled: Boolean,
- ) {
- if (this.targetIntent != null) return
- this.targetIntent = targetIntent
- this.additionalContentUri = additionalContentUri
- this.isPayloadTogglingEnabled = isPayloadTogglingEnabled
- }
-
- companion object {
- val Factory: ViewModelProvider.Factory =
- object : ViewModelProvider.Factory {
- @Suppress("UNCHECKED_CAST")
- override fun <T : ViewModel> create(
- modelClass: Class<T>,
- extras: CreationExtras
- ): 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/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
index 57a51239..ff52556a 100644
--- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt
@@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
class ShareouselContentPreviewUi : ContentPreviewUi() {
- override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE
+ override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION
override fun display(
resources: Resources,
diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
index ae7ddcd9..b12eb8cf 100644
--- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java
@@ -22,6 +22,7 @@ import android.content.res.Resources;
import android.net.Uri;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
+import android.util.Size;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -50,6 +51,7 @@ class TextContentPreviewUi extends ContentPreviewUi {
private final ChooserContentPreviewUi.ActionFactory mActionFactory;
private final HeadlineGenerator mHeadlineGenerator;
private final ContentTypeHint mContentTypeHint;
+ private int mPreviewSize;
TextContentPreviewUi(
CoroutineScope scope,
@@ -83,6 +85,7 @@ class TextContentPreviewUi extends ContentPreviewUi {
LayoutInflater layoutInflater,
ViewGroup parent,
View headlineViewParent) {
+ mPreviewSize = resources.getDimensionPixelSize(R.dimen.width_text_image_preview_size);
return displayInternal(layoutInflater, parent, headlineViewParent);
}
@@ -119,7 +122,7 @@ class TextContentPreviewUi extends ContentPreviewUi {
previewTitleView.setText(mPreviewTitle);
}
- ImageView previewThumbnailView = contentPreviewLayout.findViewById(
+ final ImageView previewThumbnailView = contentPreviewLayout.requireViewById(
com.android.internal.R.id.content_preview_thumbnail);
if (!isOwnedByCurrentUser(mPreviewThumbnail)) {
previewThumbnailView.setVisibility(View.GONE);
@@ -127,9 +130,9 @@ class TextContentPreviewUi extends ContentPreviewUi {
mImageLoader.loadImage(
mScope,
mPreviewThumbnail,
+ new Size(mPreviewSize, mPreviewSize),
(bitmap) -> updateViewWithImage(
- contentPreviewLayout.findViewById(
- com.android.internal.R.id.content_preview_thumbnail),
+ previewThumbnailView,
bitmap));
}
diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt
index 9f1d50da..e8afa480 100644
--- a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt
+++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt
@@ -20,10 +20,25 @@ import android.content.ContentResolver
import android.graphics.Bitmap
import android.net.Uri
import android.util.Size
+import com.android.intentresolver.util.withCancellationSignal
import javax.inject.Inject
/** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */
-interface ThumbnailLoader : suspend (Uri) -> Bitmap?
+interface ThumbnailLoader {
+ /**
+ * Loads a thumbnail for the given [uri].
+ *
+ * The size of the thumbnail is determined by the implementation.
+ */
+ suspend fun loadThumbnail(uri: Uri): Bitmap?
+
+ /**
+ * Loads a thumbnail for the given [uri] and [size].
+ *
+ * The [size] is the size of the thumbnail in pixels.
+ */
+ suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap?
+}
/** Default implementation of [ThumbnailLoader]. */
class ThumbnailLoaderImpl
@@ -35,6 +50,11 @@ constructor(
private val size = Size(thumbnailSize, thumbnailSize)
- override suspend fun invoke(uri: Uri): Bitmap =
- contentResolver.loadThumbnail(uri, size, /* signal = */ null)
+ override suspend fun loadThumbnail(uri: Uri): Bitmap =
+ contentResolver.loadThumbnail(uri, size, /* signal= */ null)
+
+ override suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap =
+ withCancellationSignal { signal ->
+ contentResolver.loadThumbnail(uri, size, signal)
+ }
}
diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
index 88311016..7de988c4 100644
--- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
@@ -20,6 +20,7 @@ import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTE
import android.content.res.Resources;
import android.util.Log;
+import android.util.Size;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -31,6 +32,8 @@ import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback;
import com.android.intentresolver.widget.ScrollableImagePreviewView;
+import kotlin.Pair;
+
import kotlinx.coroutines.CoroutineScope;
import kotlinx.coroutines.flow.Flow;
@@ -55,6 +58,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
@Nullable
private ViewGroup mContentPreviewView;
private View mHeadlineView;
+ private int mPreviewSize;
UnifiedContentPreviewUi(
CoroutineScope scope,
@@ -93,14 +97,18 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
LayoutInflater layoutInflater,
ViewGroup parent,
View headlineViewParent) {
+ mPreviewSize = resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen);
return displayInternal(layoutInflater, parent, headlineViewParent);
}
private void setFiles(List<FileInfo> files) {
- mImageLoader.prePopulate(files.stream()
- .map(FileInfo::getPreviewUri)
- .filter(Objects::nonNull)
- .toList());
+ Size previewSize = new Size(mPreviewSize, mPreviewSize);
+ mImageLoader.prePopulate(
+ files.stream()
+ .map(FileInfo::getPreviewUri)
+ .filter(Objects::nonNull)
+ .map((uri -> new Pair<>(uri, previewSize)))
+ .toList());
mFiles = files;
if (mContentPreviewView != null) {
updatePreviewWithFiles(mContentPreviewView, mHeadlineView, files);
@@ -121,6 +129,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
ScrollableImagePreviewView imagePreview =
mContentPreviewView.requireViewById(R.id.scrollable_image_preview);
+ imagePreview.setPreviewHeight(mPreviewSize);
imagePreview.setImageLoader(mImageLoader);
imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE));
imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback);
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt
index 81c56d1e..0688ce02 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt
@@ -18,12 +18,12 @@ package com.android.intentresolver.contentpreview.payloadtoggle.data.repository
import android.net.Uri
import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
-import dagger.hilt.android.scopes.ViewModelScoped
+import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
/** Stores set of selected previews. */
-@ViewModelScoped
+@ActivityRetainedScoped
class PreviewSelectionsRepository @Inject constructor() {
val selections = MutableStateFlow(emptyMap<Uri, PreviewModel>())
}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt
index 148310e6..2b14cdea 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt
@@ -20,6 +20,8 @@ import android.content.ContentInterface
import android.content.Intent
import android.database.Cursor
import android.net.Uri
+import android.provider.MediaStore.MediaColumns.HEIGHT
+import android.provider.MediaStore.MediaColumns.WIDTH
import android.service.chooser.AdditionalContentContract.Columns.URI
import androidx.core.os.bundleOf
import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
@@ -48,8 +50,7 @@ constructor(
runCatching {
contentResolver.query(
cursorUri,
- // TODO: uncomment to start using that data
- arrayOf(URI /*, WIDTH, HEIGHT*/),
+ arrayOf(URI, WIDTH, HEIGHT),
bundleOf(Intent.EXTRA_INTENT to chooserIntent),
signal,
)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt
index a475263c..7d658209 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt
@@ -20,6 +20,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto
import android.net.Uri
import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION
+import android.util.Log
import com.android.intentresolver.contentpreview.UriMetadataReader
import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow
import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection
@@ -51,6 +52,8 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapLatest
+private const val TAG = "CursorPreviewsIntr"
+
/** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */
class CursorPreviewsInteractor
@Inject
@@ -273,8 +276,7 @@ constructor(
pagedCursor
.getPageRows(pageNum) // TODO: what do we do if the load fails?
?.filter { it.uri !in state.merged }
- ?.toPage(this, unclaimedRecords)
- ?: this
+ ?.toPage(this, unclaimedRecords) ?: this
private suspend fun <M : MutablePreviewMap> Sequence<CursorRow>.toPage(
destination: M,
@@ -288,26 +290,32 @@ constructor(
private fun createPreviewModel(
row: CursorRow,
unclaimedRecords: MutableUnclaimedMap,
- ): PreviewModel = uriMetadataReader.getMetadata(row.uri).let { metadata ->
- val size =
- row.previewSize
- ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) }
- PreviewModel(
- uri = row.uri,
- previewUri = metadata.previewUri,
- mimeType = metadata.mimeType,
- aspectRatio = size.aspectRatioOrDefault(1f),
- order = row.position,
- )
- }.also { updated ->
- if (unclaimedRecords.remove(row.uri) != null) {
- // unclaimedRecords contains initially shared (and thus selected) items with unknown
- // cursor position. Update selection records when any of those items is encountered
- // in the cursor to maintain proper selection order should other items also be
- // selected.
- selectionInteractor.updateSelection(updated)
+ ): PreviewModel =
+ uriMetadataReader
+ .getMetadata(row.uri)
+ .let { metadata ->
+ val size =
+ row.previewSize
+ ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) }
+ PreviewModel(
+ uri = row.uri,
+ previewUri = metadata.previewUri,
+ mimeType = metadata.mimeType,
+ aspectRatio = size.aspectRatioOrDefault(1f),
+ order = row.position,
+ )
+ }
+ .also { updated ->
+ if (unclaimedRecords.remove(row.uri) != null) {
+ // unclaimedRecords contains initially shared (and thus selected) items with
+ // unknown
+ // cursor position. Update selection records when any of those items is
+ // encountered
+ // in the cursor to maintain proper selection order should other items also be
+ // selected.
+ selectionInteractor.updateSelection(updated)
+ }
}
- }
private fun <M : MutablePreviewMap> M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M =
putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx }
@@ -343,7 +351,28 @@ private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere(
.toMap(this)
private fun PagedCursor<CursorRow?>.getPageRows(pageNum: Int): Sequence<CursorRow>? =
- get(pageNum)?.filterNotNull()
+ runCatching { get(pageNum) }
+ .onFailure { Log.e(TAG, "Failed to read additional content cursor page #$pageNum", it) }
+ .getOrNull()
+ ?.asSafeSequence()
+ ?.filterNotNull()
+
+private fun <T> Sequence<T>.asSafeSequence(): Sequence<T> {
+ return if (this is SafeSequence) this else SafeSequence(this)
+}
+
+private class SafeSequence<T>(private val sequence: Sequence<T>) : Sequence<T> {
+ override fun iterator(): Iterator<T> =
+ sequence.iterator().let { if (it is SafeIterator) it else SafeIterator(it) }
+}
+
+private class SafeIterator<T>(private val iterator: Iterator<T>) : Iterator<T> by iterator {
+ override fun hasNext(): Boolean {
+ return runCatching { iterator.hasNext() }
+ .onFailure { Log.e(TAG, "Failed to read cursor", it) }
+ .getOrDefault(false)
+ }
+}
@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt
index d52a71a1..8f18ebe0 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt
@@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto
import android.net.Uri
import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
+import com.android.intentresolver.logging.EventLog
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.map
class SelectablePreviewInteractor(
private val key: PreviewModel,
private val selectionInteractor: SelectionInteractor,
+ private val eventLog: EventLog,
) {
val uri: Uri = key.uri
@@ -33,6 +35,7 @@ class SelectablePreviewInteractor(
/** Sets whether this preview is selected by the user. */
fun setSelected(isSelected: Boolean) {
+ eventLog.logPayloadSelectionChanged()
if (isSelected) {
selectionInteractor.select(key)
} else {
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt
index a578d0e2..d0ac8d4a 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt
@@ -19,6 +19,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto
import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository
import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
+import com.android.intentresolver.logging.EventLog
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
@@ -27,6 +28,7 @@ class SelectablePreviewsInteractor
constructor(
private val previewsRepo: CursorPreviewsRepository,
private val selectionInteractor: SelectionInteractor,
+ private val eventLog: EventLog,
) {
/** Keys of previews available for display in Shareousel. */
val previews: Flow<PreviewsModel?>
@@ -36,5 +38,5 @@ constructor(
* Returns a [SelectablePreviewInteractor] that can be used to interact with the individual
* preview associated with [key].
*/
- fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor)
+ fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor, eventLog)
}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt
index 97d9fa66..2d02e4fd 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt
@@ -17,6 +17,7 @@
package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
import android.net.Uri
+import com.android.intentresolver.Flags.unselectFinalItem
import com.android.intentresolver.contentpreview.MimeTypeClassifier
import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository
import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier
@@ -60,8 +61,12 @@ constructor(
}
fun unselect(model: PreviewModel) {
- if (selectionsRepo.selections.value.size > 1) {
- updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model.uri }.values)
+ if (selectionsRepo.selections.value.size > 1 || unselectFinalItem()) {
+ selectionsRepo.selections
+ .updateAndGet { it - model.uri }
+ .values
+ .takeIf { it.isNotEmpty() }
+ ?.let { updateChooserRequest(it) }
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt
index dd16f0c1..4fe5e8d5 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt
@@ -17,6 +17,7 @@
package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor
import android.content.Intent
+import com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra
import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.CustomAction
import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender
import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel
@@ -49,6 +50,12 @@ constructor(
update.refinementIntentSender.getOrDefault(current.refinementIntentSender),
metadataText = update.metadataText.getOrDefault(current.metadataText),
chooserActions = update.customActions.getOrDefault(current.chooserActions),
+ filteredComponentNames =
+ if (shareouselUpdateExcludeComponentsExtra()) {
+ update.excludeComponents.getOrDefault(current.filteredComponentNames)
+ } else {
+ current.filteredComponentNames
+ }
)
}
update.customActions.onValue { actions ->
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt
index 821e88a5..77f196e6 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt
@@ -16,6 +16,7 @@
package com.android.intentresolver.contentpreview.payloadtoggle.domain.model
+import android.content.ComponentName
import android.content.Intent
import android.content.IntentSender
import android.service.chooser.ChooserAction
@@ -31,4 +32,5 @@ data class ShareouselUpdate(
val refinementIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent,
val resultIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent,
val metadataText: ValueUpdate<CharSequence?> = ValueUpdate.Absent,
+ val excludeComponents: ValueUpdate<List<ComponentName>> = ValueUpdate.Absent,
)
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt
index 1d34dc75..184cc027 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt
@@ -16,6 +16,7 @@
package com.android.intentresolver.contentpreview.payloadtoggle.domain.update
+import android.content.ComponentName
import android.content.ContentInterface
import android.content.Intent
import android.content.Intent.EXTRA_ALTERNATE_INTENTS
@@ -24,6 +25,7 @@ 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_EXCLUDE_COMPONENTS
import android.content.Intent.EXTRA_INTENT
import android.content.Intent.EXTRA_METADATA_TEXT
import android.content.IntentSender
@@ -32,11 +34,11 @@ 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.Flags.shareouselUpdateExcludeComponentsExtra
import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate
import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
import com.android.intentresolver.inject.AdditionalContent
import com.android.intentresolver.inject.ChooserIntent
-import com.android.intentresolver.inject.ChooserServiceFlags
import com.android.intentresolver.ui.viewmodel.readAlternateIntents
import com.android.intentresolver.ui.viewmodel.readChooserActions
import com.android.intentresolver.validation.Invalid
@@ -70,7 +72,6 @@ constructor(
@AdditionalContent private val uri: Uri,
@ChooserIntent private val chooserIntent: Intent,
private val contentResolver: ContentInterface,
- private val flags: ChooserServiceFlags,
) : SelectionChangeCallback {
private val mutex = Mutex()
@@ -90,7 +91,7 @@ constructor(
)
}
?.let { bundle ->
- return when (val result = readCallbackResponse(bundle, flags)) {
+ return when (val result = readCallbackResponse(bundle)) {
is Valid -> {
result.warnings.forEach { it.log(TAG) }
result.value
@@ -105,7 +106,6 @@ constructor(
private fun readCallbackResponse(
bundle: Bundle,
- flags: ChooserServiceFlags
): ValidationResult<ShareouselUpdate> {
return validateFrom(bundle::get) {
// An error is treated as an empty collection or null as the presence of a value indicates
@@ -136,9 +136,13 @@ private fun readCallbackResponse(
optional(value<IntentSender>(key))
}
val metadataText =
- if (flags.enableSharesheetMetadataExtra()) {
- bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key ->
- optional(value<CharSequence>(key))
+ bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key ->
+ optional(value<CharSequence>(key))
+ }
+ val excludedComponents: ValueUpdate<List<ComponentName>> =
+ if (shareouselUpdateExcludeComponentsExtra()) {
+ bundle.readValueUpdate(EXTRA_EXCLUDE_COMPONENTS) { key ->
+ optional(array<ComponentName>(key)) ?: emptyList()
}
} else {
ValueUpdate.Absent
@@ -152,6 +156,7 @@ private fun readCallbackResponse(
refinementIntentSender,
resultIntentSender,
metadataText,
+ excludedComponents,
)
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
index c40ed266..4b87d227 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -44,21 +45,27 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.android.intentresolver.Flags.shareouselScrollOffscreenSelections
+import com.android.intentresolver.Flags.unselectFinalItem
import com.android.intentresolver.R
import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault
@@ -67,6 +74,8 @@ import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.Prev
import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel
import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel
import kotlin.math.abs
+import kotlin.math.min
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@Composable
@@ -100,48 +109,158 @@ private fun PreviewCarousel(
previews: PreviewsModel,
viewModel: ShareouselViewModel,
) {
- val centerIdx = previews.startIdx
- val carouselState =
- rememberLazyListState(
- initialFirstVisibleItemIndex = centerIdx,
- prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() }
- )
- // TODO: start 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),
- contentPadding = PaddingValues(start = 16.dp, end = 16.dp),
+ var maxAspectRatio by remember { mutableStateOf(0f) }
+ var viewportHeight by remember { mutableStateOf(0) }
+ var viewportCenter by remember { mutableStateOf(0) }
+ var horizontalPadding by remember { mutableStateOf(0.dp) }
+ Box(
modifier =
Modifier.fillMaxWidth()
.height(dimensionResource(R.dimen.chooser_preview_image_height_tall))
- .systemGestureExclusion()
+ .layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ val (minItemWidth, maxAR) =
+ if (placeable.height <= 0) {
+ 0f to 0f
+ } else {
+ val minItemWidth = (MIN_ASPECT_RATIO * placeable.height)
+ val maxItemWidth = maxOf(0, placeable.width - 32.dp.roundToPx())
+ val maxAR =
+ (maxItemWidth.toFloat() / placeable.height).coerceIn(
+ 0f,
+ MAX_ASPECT_RATIO
+ )
+ minItemWidth to maxAR
+ }
+ viewportCenter = placeable.width / 2
+ maxAspectRatio = maxAR
+ viewportHeight = placeable.height
+ horizontalPadding = ((placeable.width - minItemWidth) / 2).toDp()
+ layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+ },
) {
- itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model ->
+ if (maxAspectRatio <= 0 && previews.previewModels.isNotEmpty()) {
+ // Do not compose the list until we know the viewport size
+ return@Box
+ }
+
+ var firstSelectedIndex by remember { mutableStateOf(null as Int?) }
+
+ val carouselState =
+ rememberLazyListState(
+ prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() },
+ )
- // Index if this is the element in the center of the viewing area, otherwise null
- val previewIndex by remember {
- derivedStateOf {
- carouselState.layoutInfo.visibleItemsInfo
- .firstOrNull { it.index == index }
- ?.let {
- val viewportCenter = carouselState.layoutInfo.viewportEndOffset / 2
+ LazyRow(
+ state = carouselState,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding),
+ modifier = Modifier.fillMaxSize().systemGestureExclusion(),
+ ) {
+ itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model ->
+ val visibleItem by remember {
+ derivedStateOf {
+ carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
+ }
+ }
+
+ // Index if this is the element in the center of the viewing area, otherwise null
+ val previewIndex by remember {
+ derivedStateOf {
+ visibleItem?.let {
val halfPreviewWidth = it.size / 2
val previewCenter = it.offset + halfPreviewWidth
val previewDistanceToViewportCenter =
abs(previewCenter - viewportCenter)
- if (previewDistanceToViewportCenter <= halfPreviewWidth) index else null
+ if (previewDistanceToViewportCenter <= halfPreviewWidth) {
+ index
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ val previewModel =
+ viewModel.preview(model, viewportHeight, previewIndex, rememberCoroutineScope())
+ val selected by
+ previewModel.isSelected.collectAsStateWithLifecycle(initialValue = false)
+
+ if (selected) {
+ firstSelectedIndex = min(index, firstSelectedIndex ?: Int.MAX_VALUE)
+ }
+
+ if (shareouselScrollOffscreenSelections()) {
+ LaunchedEffect(index, model.uri) {
+ var current: Boolean? = null
+ previewModel.isSelected.collect { selected ->
+ when {
+ // First update will always be the current state, so we just want to
+ // record the state and do nothing else.
+ current == null -> current = selected
+
+ // We only want to act when the state changes
+ current != selected -> {
+ current = selected
+ with(carouselState.layoutInfo) {
+ visibleItemsInfo
+ .firstOrNull { it.index == index }
+ ?.let { item ->
+ when {
+ // Item is partially past start of viewport
+ item.offset < viewportStartOffset ->
+ -viewportStartOffset
+ // Item is partially past end of viewport
+ (item.offset + item.size) > viewportEndOffset ->
+ item.size - viewportEndOffset
+ // Item is fully within viewport
+ else -> null
+ }?.let { scrollOffset ->
+ carouselState.animateScrollToItem(
+ index = index,
+ scrollOffset = scrollOffset,
+ )
+ }
+ }
+ }
+ }
+ }
}
+ }
}
+
+ ShareouselCard(
+ viewModel.preview(
+ model,
+ viewportHeight,
+ previewIndex,
+ rememberCoroutineScope()
+ ),
+ maxAspectRatio,
+ )
}
+ }
+
+ firstSelectedIndex?.let { index ->
+ LaunchedEffect(Unit) {
+ val visibleItem =
+ carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
+ val center =
+ with(carouselState.layoutInfo) {
+ ((viewportEndOffset - viewportStartOffset) / 2) + viewportStartOffset
+ }
- ShareouselCard(viewModel.preview(model, previewIndex, rememberCoroutineScope()))
+ carouselState.scrollToItem(
+ index = index,
+ scrollOffset = visibleItem?.size?.div(2)?.minus(center) ?: 0,
+ )
+ }
}
}
}
@Composable
-private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) {
+private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, maxAspectRatio: Float) {
val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle()
val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false)
val borderColor = MaterialTheme.colorScheme.primary
@@ -162,8 +281,7 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) {
onValueChange = { scope.launch { viewModel.setSelected(it) } },
)
) { state ->
- // TODO: max ratio is actually equal to the viewport ratio
- val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
+ val aspectRatio = minOf(maxAspectRatio, maxOf(MIN_ASPECT_RATIO, viewModel.aspectRatio))
if (state is ValueUpdate.Value) {
state.getOrDefault(null).let { bitmap ->
ShareouselCard(
@@ -210,30 +328,46 @@ private fun ActionCarousel(viewModel: ShareouselViewModel) {
val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList())
if (actions.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
- LazyRow(
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- modifier = Modifier.height(32.dp),
- ) {
- itemsIndexed(actions) { idx, actionViewModel ->
- if (idx == 0) {
- Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal)))
- }
- ShareouselAction(
- label = actionViewModel.label,
- onClick = { actionViewModel.onClicked() },
- ) {
- actionViewModel.icon?.let {
- Image(
- icon = it,
- modifier = Modifier.size(16.dp),
- colorFilter = ColorFilter.tint(LocalContentColor.current)
+ val visibilityFlow =
+ if (unselectFinalItem()) {
+ viewModel.hasSelectedItems
+ } else {
+ MutableStateFlow(true)
+ }
+ val visibility by visibilityFlow.collectAsStateWithLifecycle(true)
+ val height = 32.dp
+ if (visibility) {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.height(height),
+ ) {
+ itemsIndexed(actions) { idx, actionViewModel ->
+ if (idx == 0) {
+ Spacer(
+ Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))
+ )
+ }
+ ShareouselAction(
+ label = actionViewModel.label,
+ onClick = { actionViewModel.onClicked() },
+ ) {
+ actionViewModel.icon?.let {
+ Image(
+ icon = it,
+ modifier = Modifier.size(16.dp),
+ colorFilter = ColorFilter.tint(LocalContentColor.current)
+ )
+ }
+ }
+ if (idx == actions.size - 1) {
+ Spacer(
+ Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))
)
}
- }
- if (idx == actions.size - 1) {
- Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal)))
}
}
+ } else {
+ Spacer(modifier = Modifier.height(height))
}
}
}
diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt
index d0b89860..ebcd58d1 100644
--- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt
@@ -15,10 +15,14 @@
*/
package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel
+import android.util.Size
+import com.android.intentresolver.Flags
+import com.android.intentresolver.Flags.unselectFinalItem
import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader
import com.android.intentresolver.contentpreview.HeadlineGenerator
import com.android.intentresolver.contentpreview.ImageLoader
import com.android.intentresolver.contentpreview.MimeTypeClassifier
+import com.android.intentresolver.contentpreview.PreviewImageLoader
import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle
import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor
import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor
@@ -29,14 +33,15 @@ import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentTyp
import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
import com.android.intentresolver.inject.ViewModelOwned
-import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
+import javax.inject.Provider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -55,95 +60,123 @@ data class ShareouselViewModel(
val previews: Flow<PreviewsModel?>,
/** List of action chips presented underneath Shareousel. */
val actions: Flow<List<ActionChipViewModel>>,
+ /** Indicates whether there are any selected items */
+ val hasSelectedItems: Flow<Boolean>,
/** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */
val preview:
- (key: PreviewModel, index: Int?, scope: CoroutineScope) -> ShareouselPreviewViewModel,
+ (
+ key: PreviewModel, previewHeight: Int, index: Int?, scope: CoroutineScope
+ ) -> ShareouselPreviewViewModel,
)
@Module
@InstallIn(ViewModelComponent::class)
-interface ShareouselViewModelModule {
+object ShareouselViewModelModule {
- @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader
+ @Provides
+ @PayloadToggle
+ fun imageLoader(
+ cachingImageLoader: Provider<CachingImagePreviewImageLoader>,
+ previewImageLoader: Provider<PreviewImageLoader>
+ ): ImageLoader =
+ if (Flags.previewImageLoader()) {
+ previewImageLoader.get()
+ } else {
+ cachingImageLoader.get()
+ }
- companion object {
- @Provides
- fun create(
- interactor: SelectablePreviewsInteractor,
- @PayloadToggle imageLoader: ImageLoader,
- actionsInteractor: CustomActionsInteractor,
- headlineGenerator: HeadlineGenerator,
- selectionInteractor: SelectionInteractor,
- chooserRequestInteractor: ChooserRequestInteractor,
- mimeTypeClassifier: MimeTypeClassifier,
- // TODO: remove if possible
- @ViewModelOwned scope: CoroutineScope,
- ): ShareouselViewModel {
- val keySet =
- interactor.previews.stateIn(
- scope,
- SharingStarted.Eagerly,
- initialValue = null,
- )
- return ShareouselViewModel(
- headline =
- selectionInteractor.aggregateContentType.zip(
- selectionInteractor.amountSelected
- ) { contentType, numItems ->
+ @Provides
+ fun create(
+ interactor: SelectablePreviewsInteractor,
+ @PayloadToggle imageLoader: ImageLoader,
+ actionsInteractor: CustomActionsInteractor,
+ headlineGenerator: HeadlineGenerator,
+ selectionInteractor: SelectionInteractor,
+ chooserRequestInteractor: ChooserRequestInteractor,
+ mimeTypeClassifier: MimeTypeClassifier,
+ // TODO: remove if possible
+ @ViewModelOwned scope: CoroutineScope,
+ ): ShareouselViewModel {
+ val keySet =
+ interactor.previews.stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ initialValue = null,
+ )
+ return ShareouselViewModel(
+ headline =
+ selectionInteractor.aggregateContentType.zip(selectionInteractor.amountSelected) {
+ contentType,
+ numItems ->
+ if (unselectFinalItem() && numItems == 0) {
+ headlineGenerator.getNotItemsSelectedHeadline()
+ } else {
when (contentType) {
ContentType.Other -> headlineGenerator.getFilesHeadline(numItems)
ContentType.Image -> headlineGenerator.getImagesHeadline(numItems)
ContentType.Video -> headlineGenerator.getVideosHeadline(numItems)
}
- },
- metadataText = chooserRequestInteractor.metadataText,
- previews = keySet,
- actions =
- actionsInteractor.customActions.map { actions ->
- actions.mapIndexedNotNull { i, model ->
- val icon = model.icon
- val label = model.label
- if (icon == null && label.isBlank()) {
- null
- } else {
- ActionChipViewModel(
- label = label.toString(),
- icon = model.icon,
- onClicked = { model.performAction(i) },
- )
- }
- }
- },
- preview = { key, index, previewScope ->
- keySet.value?.maybeLoad(index)
- val previewInteractor = interactor.preview(key)
- val contentType =
- when {
- mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image
- mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video
- else -> ContentType.Other
+ }
+ },
+ metadataText = chooserRequestInteractor.metadataText,
+ previews = keySet,
+ actions =
+ actionsInteractor.customActions.map { actions ->
+ actions.mapIndexedNotNull { i, model ->
+ val icon = model.icon
+ val label = model.label
+ if (icon == null && label.isBlank()) {
+ null
+ } else {
+ ActionChipViewModel(
+ label = label.toString(),
+ icon = model.icon,
+ onClicked = { model.performAction(i) },
+ )
}
- val initialBitmapValue =
- key.previewUri?.let {
- imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) }
- } ?: ValueUpdate.Absent
- ShareouselPreviewViewModel(
- bitmapLoadState =
- flow {
- emit(
- key.previewUri?.let { ValueUpdate.Value(imageLoader(it)) }
- ?: ValueUpdate.Absent
- )
- }
- .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue),
- contentType = contentType,
- isSelected = previewInteractor.isSelected,
- setSelected = previewInteractor::setSelected,
- aspectRatio = key.aspectRatio,
- )
+ }
},
- )
- }
+ hasSelectedItems =
+ selectionInteractor.selections.map { it.isNotEmpty() }.distinctUntilChanged(),
+ preview = { key, previewHeight, index, previewScope ->
+ keySet.value?.maybeLoad(index)
+ val previewInteractor = interactor.preview(key)
+ val contentType =
+ when {
+ mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image
+ mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video
+ else -> ContentType.Other
+ }
+ val initialBitmapValue =
+ key.previewUri?.let {
+ imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) }
+ } ?: ValueUpdate.Absent
+ ShareouselPreviewViewModel(
+ bitmapLoadState =
+ flow {
+ val previewWidth =
+ if (key.aspectRatio > 0) {
+ previewHeight.toFloat() / key.aspectRatio
+ } else {
+ previewHeight
+ }
+ .toInt()
+ emit(
+ key.previewUri?.let {
+ ValueUpdate.Value(
+ imageLoader(it, Size(previewWidth, previewHeight))
+ )
+ } ?: ValueUpdate.Absent
+ )
+ }
+ .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue),
+ contentType = contentType,
+ isSelected = previewInteractor.isSelected,
+ setSelected = previewInteractor::setSelected,
+ aspectRatio = key.aspectRatio,
+ )
+ },
+ )
}
}
diff --git a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt
index 045a17f6..c4aa2b98 100644
--- a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt
+++ b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt
@@ -156,6 +156,8 @@ data class ChooserRequest(
* TODO: Constrain length?
*/
val sharedText: CharSequence? = null,
+ /** Contains title to the text content to share supplied by the source app. */
+ val sharedTextTitle: CharSequence? = null,
/**
* Supplied to
diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
index 7cf9d2e9..1dd83566 100644
--- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
+++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
@@ -150,11 +150,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.
public void setFooterHeight(int 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);
- }
+ // we always have at least one view, the footer, see getItemCount() and
+ // getFooterRowCount()
+ notifyItemChanged(getItemCount() - 1);
}
}
diff --git a/java/src/com/android/intentresolver/logging/EventLog.kt b/java/src/com/android/intentresolver/logging/EventLog.kt
index 476bd4bf..b92f0732 100644
--- a/java/src/com/android/intentresolver/logging/EventLog.kt
+++ b/java/src/com/android/intentresolver/logging/EventLog.kt
@@ -47,6 +47,7 @@ interface EventLog {
)
fun logCustomActionSelected(positionPicked: Int)
+
fun logShareTargetSelected(
targetType: Int,
packageName: String?,
@@ -60,15 +61,29 @@ interface EventLog {
)
fun logDirectShareTargetReceived(category: Int, latency: Int)
+
fun logActionShareWithPreview(previewType: Int)
+
fun logActionSelected(targetType: Int)
+
fun logContentPreviewWarning(uri: Uri?)
+
fun logSharesheetTriggered()
+
fun logSharesheetAppLoadComplete()
+
fun logSharesheetDirectLoadComplete()
+
fun logSharesheetDirectLoadTimeout()
+
fun logSharesheetProfileChanged()
+
fun logSharesheetExpansionChanged(isCollapsed: Boolean)
+
fun logSharesheetAppShareRankingTimeout()
+
fun logSharesheetEmptyDirectShareRow()
+
+ /** Log payload selection */
+ fun logPayloadSelectionChanged()
}
diff --git a/java/src/com/android/intentresolver/logging/EventLogImpl.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java
index 39d23865..8e9543bc 100644
--- a/java/src/com/android/intentresolver/logging/EventLogImpl.java
+++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java
@@ -273,6 +273,11 @@ public class EventLogImpl implements EventLog {
log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, mInstanceId);
}
+ @Override
+ public void logPayloadSelectionChanged() {
+ log(SharesheetStandardEvent.SHARESHEET_PAYLOAD_TOGGLED, mInstanceId);
+ }
+
/**
* Logs a UiEventReported event for a given share activity
* @param event
@@ -402,6 +407,9 @@ public class EventLogImpl implements EventLog {
case ContentPreviewType.CONTENT_PREVIEW_FILE:
return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE;
case ContentPreviewType.CONTENT_PREVIEW_TEXT:
+ case ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION:
+ return FrameworkStatsLog
+ .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TOGGLEABLE_MEDIA;
default:
return FrameworkStatsLog
.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN;
diff --git a/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java
index 8aee0da1..9176cd35 100644
--- a/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java
@@ -112,6 +112,15 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter<
}
}
+ /**
+ * Set enabled status for all targets in all profiles.
+ */
+ public void setTargetsEnabled(boolean isEnabled) {
+ for (int i = 0, size = getItemCount(); i < size; i++) {
+ getPageAdapterForIndex(i).getListAdapter().setTargetsEnabled(isEnabled);
+ }
+ }
+
private static ViewGroup makeProfileView(Context context) {
LayoutInflater inflater = LayoutInflater.from(context);
ViewGroup rootView =
diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
index 08230d90..828d8561 100644
--- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
+++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt
@@ -35,16 +35,23 @@ import androidx.annotation.MainThread
import androidx.annotation.OpenForTesting
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
+import com.android.intentresolver.Flags.fixShortcutLoaderJobLeak
+import com.android.intentresolver.Flags.fixShortcutsFlashing
import com.android.intentresolver.chooser.DisplayResolveInfo
import com.android.intentresolver.measurements.Tracer
import com.android.intentresolver.measurements.runTracing
import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicReference
import java.util.function.Consumer
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
@@ -65,29 +72,35 @@ open class ShortcutLoader
@VisibleForTesting
constructor(
private val context: Context,
- private val scope: CoroutineScope,
+ parentScope: CoroutineScope,
private val appPredictor: AppPredictorProxy?,
private val userHandle: UserHandle,
private val isPersonalProfile: Boolean,
private val targetIntentFilter: IntentFilter?,
private val dispatcher: CoroutineDispatcher,
- private val callback: Consumer<Result>
+ private val callback: Consumer<Result>,
) {
+ private val scope =
+ if (fixShortcutLoaderJobLeak()) parentScope.createChildScope() else parentScope
private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter()
private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
+ private val appPredictorWatchdog = AtomicReference<Job?>(null)
private val appPredictorCallback =
ScopedAppTargetListCallback(scope) { onAppPredictorCallback(it) }.toAppPredictorCallback()
private val appTargetSource =
MutableSharedFlow<Array<DisplayResolveInfo>?>(
replay = 1,
- onBufferOverflow = BufferOverflow.DROP_OLDEST
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val shortcutSource =
MutableSharedFlow<ShortcutData?>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val isDestroyed
get() = !scope.isActive
+ private val id
+ get() = System.identityHashCode(this).toString(Character.MAX_RADIX)
+
@MainThread
constructor(
context: Context,
@@ -95,7 +108,7 @@ constructor(
appPredictor: AppPredictor?,
userHandle: UserHandle,
targetIntentFilter: IntentFilter?,
- callback: Consumer<Result>
+ callback: Consumer<Result>,
) : this(
context,
scope,
@@ -104,7 +117,7 @@ constructor(
userHandle == UserHandle.of(ActivityManager.getCurrentUser()),
targetIntentFilter,
Dispatchers.IO,
- callback
+ callback,
)
init {
@@ -121,7 +134,7 @@ constructor(
appTargets,
shortcutData.shortcuts,
shortcutData.isFromAppPredictor,
- shortcutData.appPredictorTargets
+ shortcutData.appPredictorTargets,
)
}
}
@@ -132,7 +145,7 @@ constructor(
}
.invokeOnCompletion {
runCatching { appPredictor?.unregisterPredictionUpdates(appPredictorCallback) }
- Log.d(TAG, "destroyed, user: $userHandle")
+ Log.d(TAG, "[$id] destroyed, user: $userHandle")
}
reset()
}
@@ -140,7 +153,7 @@ constructor(
/** Clear application targets (see [updateAppTargets] and initiate shortcuts loading. */
@OpenForTesting
open fun reset() {
- Log.d(TAG, "reset shortcut loader for user $userHandle")
+ Log.d(TAG, "[$id] reset shortcut loader for user $userHandle")
appTargetSource.tryEmit(null)
shortcutSource.tryEmit(null)
scope.launch(dispatcher) { loadShortcuts() }
@@ -155,14 +168,21 @@ constructor(
appTargetSource.tryEmit(appTargets)
}
+ @OpenForTesting
+ open fun destroy() {
+ if (fixShortcutLoaderJobLeak()) {
+ scope.cancel()
+ }
+ }
+
@WorkerThread
private fun loadShortcuts() {
// no need to query direct share for work profile when its locked or disabled
if (!shouldQueryDirectShareTargets()) {
- Log.d(TAG, "skip shortcuts loading for user $userHandle")
+ Log.d(TAG, "[$id] skip shortcuts loading for user $userHandle")
return
}
- Log.d(TAG, "querying direct share targets for user $userHandle")
+ Log.d(TAG, "[$id] querying direct share targets for user $userHandle")
queryDirectShareTargets(false)
}
@@ -170,9 +190,30 @@ constructor(
private fun queryDirectShareTargets(skipAppPredictionService: Boolean) {
if (!skipAppPredictionService && appPredictor != null) {
try {
- Log.d(TAG, "query AppPredictor for user $userHandle")
+ Log.d(TAG, "[$id] query AppPredictor for user $userHandle")
+
+ val watchdogJob =
+ if (fixShortcutsFlashing()) {
+ scope
+ .launch(start = CoroutineStart.LAZY) {
+ delay(APP_PREDICTOR_RESPONSE_TIMEOUT_MS)
+ Log.w(TAG, "AppPredictor response timeout for user: $userHandle")
+ appPredictorCallback.onTargetsAvailable(emptyList())
+ }
+ .also { job ->
+ appPredictorWatchdog.getAndSet(job)?.cancel()
+ job.invokeOnCompletion {
+ appPredictorWatchdog.compareAndSet(job, null)
+ }
+ }
+ } else {
+ null
+ }
+
Tracer.beginAppPredictorQueryTrace(userHandle)
appPredictor.requestPredictionUpdate()
+
+ watchdogJob?.start()
return
} catch (e: Throwable) {
endAppPredictorQueryTrace(userHandle)
@@ -180,25 +221,25 @@ constructor(
if (isDestroyed) {
return
}
- Log.e(TAG, "Failed to query AppPredictor for user $userHandle", e)
+ Log.e(TAG, "[$id] failed to query AppPredictor for user $userHandle", e)
}
}
// Default to just querying ShortcutManager if AppPredictor not present.
if (targetIntentFilter == null) {
- Log.d(TAG, "skip querying ShortcutManager for $userHandle")
+ Log.d(TAG, "[$id] skip querying ShortcutManager for $userHandle")
sendShareShortcutInfoList(
emptyList(),
isFromAppPredictor = false,
- appPredictorTargets = null
+ appPredictorTargets = null,
)
return
}
- Log.d(TAG, "query ShortcutManager for user $userHandle")
+ Log.d(TAG, "[$id] query ShortcutManager for user $userHandle")
val shortcuts =
runTracing("shortcut-mngr-${userHandle.identifier}") {
queryShortcutManager(targetIntentFilter)
}
- Log.d(TAG, "receive shortcuts from ShortcutManager for user $userHandle")
+ Log.d(TAG, "[$id] receive shortcuts from ShortcutManager for user $userHandle")
sendShareShortcutInfoList(shortcuts, false, null)
}
@@ -210,14 +251,14 @@ constructor(
val pm = context.createContextAsUser(userHandle, 0 /* flags */).packageManager
return sm?.getShareTargets(targetIntentFilter)?.filter {
pm.isPackageEnabled(it.targetComponent.packageName)
- }
- ?: emptyList()
+ } ?: emptyList()
}
@WorkerThread
private fun onAppPredictorCallback(appPredictorTargets: List<AppTarget>) {
+ appPredictorWatchdog.get()?.cancel()
endAppPredictorQueryTrace(userHandle)
- Log.d(TAG, "receive app targets from AppPredictor")
+ Log.d(TAG, "[$id] receive app targets from AppPredictor")
if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) {
// APS may be disabled, so try querying targets ourselves.
queryDirectShareTargets(true)
@@ -247,7 +288,7 @@ constructor(
private fun sendShareShortcutInfoList(
shortcuts: List<ShareShortcutInfo>,
isFromAppPredictor: Boolean,
- appPredictorTargets: List<AppTarget>?
+ appPredictorTargets: List<AppTarget>?,
) {
shortcutSource.tryEmit(ShortcutData(shortcuts, isFromAppPredictor, appPredictorTargets))
}
@@ -256,7 +297,7 @@ constructor(
appTargets: Array<DisplayResolveInfo>,
shortcuts: List<ShareShortcutInfo>,
isFromAppPredictor: Boolean,
- appPredictorTargets: List<AppTarget>?
+ appPredictorTargets: List<AppTarget>?,
): Result {
if (appPredictorTargets != null && appPredictorTargets.size != shortcuts.size) {
throw RuntimeException(
@@ -283,7 +324,7 @@ constructor(
shortcuts,
appPredictorTargets,
directShareAppTargetCache,
- directShareShortcutInfoCache
+ directShareShortcutInfoCache,
)
val resultRecord = ShortcutResultInfo(displayResolveInfo, chooserTargets)
resultRecords.add(resultRecord)
@@ -293,7 +334,7 @@ constructor(
appTargets,
resultRecords.toTypedArray(),
directShareAppTargetCache,
- directShareShortcutInfoCache
+ directShareShortcutInfoCache,
)
}
@@ -313,7 +354,7 @@ constructor(
private class ShortcutData(
val shortcuts: List<ShareShortcutInfo>,
val isFromAppPredictor: Boolean,
- val appPredictorTargets: List<AppTarget>?
+ val appPredictorTargets: List<AppTarget>?,
)
/** Resolved shortcuts with corresponding app targets. */
@@ -327,18 +368,23 @@ constructor(
/** Shortcuts grouped by app target. */
val shortcutsByApp: Array<ShortcutResultInfo>,
val directShareAppTargetCache: Map<ChooserTarget, AppTarget>,
- val directShareShortcutInfoCache: Map<ChooserTarget, ShortcutInfo>
+ val directShareShortcutInfoCache: Map<ChooserTarget, ShortcutInfo>,
)
+ private fun endAppPredictorQueryTrace(userHandle: UserHandle) {
+ val duration = Tracer.endAppPredictorQueryTrace(userHandle)
+ Log.d(TAG, "[$id] AppPredictor query duration for user $userHandle: $duration ms")
+ }
+
/** Shortcuts grouped by app. */
class ShortcutResultInfo(
val appTarget: DisplayResolveInfo,
- val shortcuts: List<ChooserTarget?>
+ val shortcuts: List<ChooserTarget?>,
)
private class ShortcutsAppTargetsPair(
val shortcuts: List<ShareShortcutInfo>,
- val appTargets: List<AppTarget>?
+ val appTargets: List<AppTarget>?,
)
/** A wrapper around AppPredictor to facilitate unit-testing. */
@@ -347,7 +393,7 @@ constructor(
/** [AppPredictor.registerPredictionUpdates] */
open fun registerPredictionUpdates(
callbackExecutor: Executor,
- callback: AppPredictor.Callback
+ callback: AppPredictor.Callback,
) = mAppPredictor.registerPredictionUpdates(callbackExecutor, callback)
/** [AppPredictor.unregisterPredictionUpdates] */
@@ -359,6 +405,7 @@ constructor(
}
companion object {
+ @VisibleForTesting const val APP_PREDICTOR_RESPONSE_TIMEOUT_MS = 2_000L
private const val TAG = "ShortcutLoader"
private fun PackageManager.isPackageEnabled(packageName: String): Boolean {
@@ -371,16 +418,19 @@ constructor(
packageName,
PackageManager.ApplicationInfoFlags.of(
PackageManager.GET_META_DATA.toLong()
- )
+ ),
)
appInfo.enabled && (appInfo.flags and ApplicationInfo.FLAG_SUSPENDED) == 0
}
.getOrDefault(false)
}
- private fun endAppPredictorQueryTrace(userHandle: UserHandle) {
- val duration = Tracer.endAppPredictorQueryTrace(userHandle)
- Log.d(TAG, "AppPredictor query duration for user $userHandle: $duration ms")
- }
+ /**
+ * Creates a new coroutine scope and makes its job a child of the given, `this`, coroutine
+ * scope's job. This ensures that the new scope will be canceled when the parent scope is
+ * canceled (but not vice versa).
+ */
+ private fun CoroutineScope.createChildScope() =
+ CoroutineScope(coroutineContext + Job(parent = coroutineContext[Job]))
}
}
diff --git a/java/src/com/android/intentresolver/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/ui/ShareResultSender.kt
index 7be2076e..dce477ec 100644
--- a/java/src/com/android/intentresolver/ui/ShareResultSender.kt
+++ b/java/src/com/android/intentresolver/ui/ShareResultSender.kt
@@ -47,7 +47,7 @@ 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)
+ fun onComponentSelected(component: ComponentName, directShare: Boolean, crossProfile: Boolean)
/** Reports user invocation of a built-in system action. See [ShareAction]. */
fun onActionSelected(action: ShareAction)
@@ -88,11 +88,15 @@ class ShareResultSenderImpl(
IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) }
)
- override fun onComponentSelected(component: ComponentName, directShare: Boolean) {
- Log.i(TAG, "onComponentSelected: $component directShare=$directShare")
+ override fun onComponentSelected(
+ component: ComponentName,
+ directShare: Boolean,
+ crossProfile: Boolean
+ ) {
+ Log.i(TAG, "onComponentSelected: $component directShare=$directShare cross=$crossProfile")
scope.launch {
- val intent = createChosenComponentIntent(component, directShare)
- intentDispatcher.dispatchIntent(resultSender, intent)
+ val intent = createChosenComponentIntent(component, directShare, crossProfile)
+ intent?.let { intentDispatcher.dispatchIntent(resultSender, it) }
}
}
@@ -112,20 +116,38 @@ class ShareResultSenderImpl(
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+
+ crossProfile: Boolean,
+ ): Intent? {
if (flags.enableChooserResult() && chooserResultSupported(callerUid)) {
- intent.putExtra(
- Intent.EXTRA_CHOOSER_RESULT,
- ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct)
- )
+ if (crossProfile) {
+ Log.i(TAG, "Redacting package from cross-profile ${Intent.EXTRA_CHOOSER_RESULT}")
+ return Intent()
+ .putExtra(
+ Intent.EXTRA_CHOOSER_RESULT,
+ ChooserResult(CHOOSER_RESULT_UNKNOWN, null, direct)
+ )
+ } else {
+ // Add extra with component name for backwards compatibility.
+ val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component)
+
+ // Add ChooserResult value for Android V+
+ intent.putExtra(
+ Intent.EXTRA_CHOOSER_RESULT,
+ ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct)
+ )
+ return intent
+ }
} else {
- Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}")
+ if (crossProfile) {
+ // We can only send cross-profile results in the new ChooserResult format.
+ Log.i(TAG, "Omitting selection callback for cross-profile target")
+ return null
+ } else {
+ val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component)
+ Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}")
+ return intent
+ }
}
- return intent
}
@ResultType
diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt
index a9b6de7e..4a194db9 100644
--- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt
+++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt
@@ -18,7 +18,10 @@ package com.android.intentresolver.ui.viewmodel
import android.content.ComponentName
import android.content.Intent
import android.content.Intent.EXTRA_ALTERNATE_INTENTS
+import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI
+import android.content.Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT
import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS
+import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION
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
@@ -95,8 +98,7 @@ fun readChooserRequest(
val initialIntents =
optional(array<Intent>(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map {
it.maybeAddSendActionFlags()
- }
- ?: emptyList()
+ } ?: emptyList()
val chosenComponentSender =
optional(value<IntentSender>(EXTRA_CHOOSER_RESULT_INTENT_SENDER))
@@ -115,7 +117,8 @@ fun readChooserRequest(
val retainInOnStop =
optional(value<Boolean>(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false
- val sharedText = optional(value<CharSequence>(EXTRA_TEXT))
+ val sharedTextTitle = targetIntent.getCharSequenceExtra(EXTRA_TITLE)
+ val sharedText = targetIntent.getCharSequenceExtra(EXTRA_TEXT)
val chooserActions = readChooserActions() ?: emptyList()
@@ -124,29 +127,20 @@ fun readChooserRequest(
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
+ additionalContentUri = optional(value<Uri>(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI))
+ focusedItemPos = optional(value<Int>(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
+ when (optional(value<Int>(EXTRA_CHOOSER_CONTENT_TYPE_HINT))) {
+ Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM
+ else -> ContentTypeHint.NONE
}
- val metadataText =
- if (flags.enableSharesheetMetadataExtra()) {
- optional(value<CharSequence>(EXTRA_METADATA_TEXT))
- } else {
- null
- }
+ val metadataText = optional(value<CharSequence>(EXTRA_METADATA_TEXT))
ChooserRequest(
targetIntent = targetIntent,
@@ -171,6 +165,7 @@ fun readChooserRequest(
chosenComponentSender = chosenComponentSender,
refinementIntentSender = refinementIntentSender,
sharedText = sharedText,
+ sharedTextTitle = sharedTextTitle,
shareTargetFilter = targetIntent.toShareTargetFilter(),
additionalContentUri = additionalContentUri,
focusedItemPosition = focusedItemPos,
diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt
index c9cae3db..619e118a 100644
--- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt
+++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt
@@ -15,10 +15,13 @@
*/
package com.android.intentresolver.ui.viewmodel
+import android.content.ContentInterface
import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.android.intentresolver.contentpreview.ImageLoader
+import com.android.intentresolver.contentpreview.PreviewDataProvider
import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor
import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ProcessTargetIntentUpdatesInteractor
import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel
@@ -38,6 +41,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
private const val TAG = "ChooserViewModel"
@@ -58,6 +62,8 @@ constructor(
*/
val initialRequest: ValidationResult<ChooserRequest>,
private val chooserRequestRepository: Lazy<ChooserRequestRepository>,
+ private val contentResolver: ContentInterface,
+ val imageLoader: ImageLoader,
) : ViewModel() {
/** Parcelable-only references provided from the creating Activity */
@@ -86,6 +92,17 @@ constructor(
val request: StateFlow<ChooserRequest>
get() = chooserRequestRepository.get().chooserRequest.asStateFlow()
+ val previewDataProvider by lazy {
+ val chooserRequest = (initialRequest as Valid<ChooserRequest>).value
+ PreviewDataProvider(
+ viewModelScope + bgDispatcher,
+ chooserRequest.targetIntent,
+ chooserRequest.additionalContentUri,
+ contentResolver,
+ flags,
+ )
+ }
+
init {
if (initialRequest is Invalid) {
Log.w(TAG, "initialRequest is Invalid, initialization failed")
diff --git a/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt b/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt
new file mode 100644
index 00000000..3e2d8e2a
--- /dev/null
+++ b/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt
@@ -0,0 +1,46 @@
+/*
+ * 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("SuspendedMatrixColorFilter")
+
+package com.android.intentresolver.util.graphics
+
+import android.graphics.ColorMatrix
+import android.graphics.ColorMatrixColorFilter
+
+val suspendedColorMatrix by lazy {
+ val grayValue = 127f
+ val scale = 0.5f // half bright
+
+ val tempBrightnessMatrix =
+ ColorMatrix().apply {
+ array.let { m ->
+ m[0] = scale
+ m[6] = scale
+ m[12] = scale
+ m[4] = grayValue
+ m[9] = grayValue
+ m[14] = grayValue
+ }
+ }
+
+ val matrix =
+ ColorMatrix().apply {
+ setSaturation(0.0f)
+ preConcat(tempBrightnessMatrix)
+ }
+ ColorMatrixColorFilter(matrix)
+}
diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
index 7fe16091..c706e3ee 100644
--- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
+++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
@@ -22,6 +22,7 @@ import android.graphics.Rect
import android.net.Uri
import android.util.AttributeSet
import android.util.PluralsMessageFormatter
+import android.util.Size
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
@@ -60,11 +61,13 @@ private const val MIN_ASPECT_RATIO_STRING = "2:5"
private const val MAX_ASPECT_RATIO = 2.5f
private const val MAX_ASPECT_RATIO_STRING = "5:2"
-private typealias CachingImageLoader = suspend (Uri, Boolean) -> Bitmap?
+private typealias CachingImageLoader = suspend (Uri, Size, Boolean) -> Bitmap?
class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
constructor(context: Context) : this(context, null)
+
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
+
constructor(
context: Context,
attrs: AttributeSet?,
@@ -121,12 +124,19 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
* A hint about the maximum width this view can grow to, this helps to optimize preview loading.
*/
var maxWidthHint: Int = -1
+
private var requestedHeight: Int = 0
private var isMeasured = false
private var maxAspectRatio = MAX_ASPECT_RATIO
private var maxAspectRatioString = MAX_ASPECT_RATIO_STRING
private var outerSpacing: Int = 0
+ var previewHeight: Int
+ get() = previewAdapter.previewHeight
+ set(value) {
+ previewAdapter.previewHeight = value
+ }
+
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
super.onMeasure(widthSpec, heightSpec)
if (!isMeasured) {
@@ -198,6 +208,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
BatchPreviewLoader(
previewAdapter.imageLoader ?: error("Image loader is not set"),
previews,
+ Size(previewHeight, previewHeight),
totalItemCount,
onUpdate = previewAdapter::addPreviews,
onCompletion = {
@@ -303,11 +314,19 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
private var isLoading = false
private val hasOtherItem
get() = previews.size < totalItemCount
+
val hasPreviews: Boolean
get() = previews.isNotEmpty()
var transitionStatusElementCallback: TransitionElementStatusCallback? = null
+ private var previewSize: Size = Size(0, 0)
+ var previewHeight: Int
+ get() = previewSize.height
+ set(value) {
+ previewSize = Size(value, value)
+ }
+
fun reset(totalItemCount: Int) {
firstImagePos = -1
previews.clear()
@@ -387,6 +406,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
vh.bind(
previews[position],
imageLoader ?: error("ImageLoader is missing"),
+ previewSize,
fadeInDurationMs,
isSharedTransitionElement = position == firstImagePos,
previewReadyCallback =
@@ -438,6 +458,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
fun bind(
preview: Preview,
imageLoader: CachingImageLoader,
+ previewSize: Size,
fadeInDurationMs: Long,
isSharedTransitionElement: Boolean,
previewReadyCallback: ((String) -> Unit)?
@@ -477,7 +498,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
}
}
resetScope().launch {
- loadImage(preview, imageLoader)
+ loadImage(preview, previewSize, imageLoader)
if (preview.type == PreviewType.Image && previewReadyCallback != null) {
image.waitForPreDraw()
previewReadyCallback(TRANSITION_NAME)
@@ -487,12 +508,16 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
}
}
- private suspend fun loadImage(preview: Preview, imageLoader: CachingImageLoader) {
+ private suspend fun loadImage(
+ preview: Preview,
+ previewSize: Size,
+ imageLoader: CachingImageLoader,
+ ) {
val bitmap =
runCatching {
// it's expected for all loading/caching optimizations to be implemented by
// the loader
- imageLoader(preview.uri, true)
+ imageLoader(preview.uri, previewSize, true)
}
.getOrNull()
image.setImageBitmap(bitmap)
@@ -507,6 +532,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
setAnimationListener(
object : AnimationListener {
override fun onAnimationStart(animation: Animation?) = Unit
+
override fun onAnimationRepeat(animation: Animation?) = Unit
override fun onAnimationEnd(animation: Animation?) {
@@ -551,6 +577,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
private class LoadingItemViewHolder(view: View) : ViewHolder(view) {
fun bind() = Unit
+
override fun unbind() = Unit
}
@@ -638,6 +665,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
class BatchPreviewLoader(
private val imageLoader: CachingImageLoader,
private val previews: Flow<Preview>,
+ private val previewSize: Size,
val totalItemCount: Int,
private val onUpdate: (List<Preview>) -> Unit,
private val onCompletion: () -> Unit,
@@ -701,10 +729,10 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
// imagine is one of the first images never loads so we never
// fill the initial viewport and does not show the previews at
// all.
- imageLoader(preview.uri, isFirstBlock)?.let { bitmap ->
+ imageLoader(preview.uri, previewSize, isFirstBlock)?.let {
+ bitmap ->
previewSizeUpdater(preview, bitmap.width, bitmap.height)
- }
- ?: 0
+ } ?: 0
}
.getOrDefault(0)