diff options
author | 2024-11-06 10:31:34 -0800 | |
---|---|---|
committer | 2024-11-06 10:31:34 -0800 | |
commit | fca7f4058678568daa0e95f8fdf97ea69d887c7a (patch) | |
tree | 8cb98b028828ccca6bec82d1ecb80ccc56bfc519 /java/src | |
parent | 68b62d37ebe6bbc49d149e2bd5d548afe7415e23 (diff) | |
parent | 49474fb831775a91a283517e14be92088216d8b7 (diff) |
Merge 24Q4 (ab/12406339) into aosp-main-future
Bug: 370570306
Merged-In: I6840e0687b78b38df7ac5d187bf147e0c5a33e24
Change-Id: I66a1a2533efa845683e66a799263fe5a18ba84b4
Diffstat (limited to 'java/src')
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) |