diff options
70 files changed, 2218 insertions, 716 deletions
@@ -63,7 +63,7 @@ android_library { "java/res", ], - manifest: "AndroidManifest.xml", + manifest: "AndroidManifest-lib.xml", static_libs: [ "androidx.annotation_annotation", @@ -75,6 +75,9 @@ android_library { "androidx.lifecycle_lifecycle-extensions", "androidx.lifecycle_lifecycle-runtime-ktx", "androidx.lifecycle_lifecycle-viewmodel-ktx", + "androidx.savedstate_savedstate-ktx", + "dagger2", + "jsr330", "kotlin-stdlib", "kotlinx_coroutines", "kotlinx-coroutines-android", @@ -83,9 +86,15 @@ android_library { "SystemUIFlagsLib", ], + plugins: ["dagger2-compiler"], + lint: { strict_updatability_linting: false, }, + + optimize: { + proguard_flags_files: ["proguard.flags"], + }, } android_app { @@ -93,6 +102,7 @@ android_app { min_sdk_version: "current", certificate: "platform", privileged: true, + manifest: "AndroidManifest-app.xml", required: [ "privapp_whitelist_com.android.intentresolver", ], diff --git a/AndroidManifest.xml b/AndroidManifest-app.xml index da781a22..ba9afe28 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest-app.xml @@ -17,44 +17,29 @@ */ --> <manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.android.intentresolver" - android:versionCode="0" - android:versionName="2021-11" - coreApp="true"> - - - <uses-permission android:name="android.permission.ACCESS_SHORTCUTS" /> - <uses-permission android:name="android.permission.BIND_RESOLVER_RANKER_SERVICE" /> - <uses-permission android:name="android.permission.GET_ANY_PROVIDER_TYPE" /> - <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL" /> - <uses-permission android:name="android.permission.MANAGE_APP_PREDICTIONS" /> - <uses-permission android:name="android.permission.MANAGE_USERS" /> - <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" /> - <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> - <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" /> - <uses-permission android:name="android.permission.SET_CLIP_SOURCE" /> - <uses-permission android:name="android.permission.START_ACTIVITY_AS_CALLER" /> - <uses-permission android:name="android.permission.UNLIMITED_SHORTCUTS_API_CALLS" /> - <uses-permission android:name="android.permission.QUERY_CLONED_APPS" /> - <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> + xmlns:tools="http://schemas.android.com/tools" + package="com.android.intentresolver" + android:versionCode="0" + android:versionName="2021-11" + coreApp="true"> <application + android:name=".IntentResolverApplication" android:hardwareAccelerated="true" android:label="@string/app_label" android:directBootAware="true" android:forceQueryable="true" android:requiredForAllUsers="true" - android:supportsRtl="true"> + android:supportsRtl="true" + tools:replace="android:appComponentFactory" + android:appComponentFactory=".dagger.InjectedAppComponentFactory"> - <activity android:name=".ChooserActivity" - android:theme="@style/Theme.DeviceDefault.Chooser" - android:finishOnCloseSystemDialogs="true" - android:excludeFromRecents="true" - android:documentLaunchMode="never" - android:relinquishTaskIdentity="true" - android:configChanges="screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden" - android:visibleToInstantApps="true" - android:exported="true"> + <!-- This alias needs to be maintained until there are no more devices that could be + upgrading from T QPR3. (b/283722356) --> + <activity-alias + android:name=".ChooserActivityLauncher" + android:targetActivity=".ChooserActivity" + android:exported="true"> <!-- This intent filter is assigned a priority greater than 100 so that it will take precedence over the framework ChooserActivity @@ -65,14 +50,17 @@ <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.VOICE" /> </intent-filter> - </activity> + </activity-alias> - <receiver android:name=".ChooserActivityReEnabler" - android:exported="true"> - <intent-filter> - <action android:name="android.intent.action.BOOT_COMPLETED" /> - </intent-filter> - </receiver> + <activity android:name=".ChooserActivity" + android:theme="@style/Theme.DeviceDefault.Chooser" + android:finishOnCloseSystemDialogs="true" + android:excludeFromRecents="true" + android:documentLaunchMode="never" + android:relinquishTaskIdentity="true" + android:configChanges="screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden" + android:visibleToInstantApps="true" + android:exported="false"/> </application> diff --git a/AndroidManifest-lib.xml b/AndroidManifest-lib.xml new file mode 100644 index 00000000..509d46a5 --- /dev/null +++ b/AndroidManifest-lib.xml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.intentresolver" > + <uses-permission android:name="android.permission.ACCESS_SHORTCUTS" /> + <uses-permission android:name="android.permission.BIND_RESOLVER_RANKER_SERVICE" /> + <uses-permission android:name="android.permission.GET_ANY_PROVIDER_TYPE" /> + <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL" /> + <uses-permission android:name="android.permission.MANAGE_APP_PREDICTIONS" /> + <uses-permission android:name="android.permission.MANAGE_USERS" /> + <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" /> + <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> + <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" /> + <uses-permission android:name="android.permission.SET_CLIP_SOURCE" /> + <uses-permission android:name="android.permission.START_ACTIVITY_AS_CALLER" /> + <uses-permission android:name="android.permission.UNLIMITED_SHORTCUTS_API_CALLS" /> + <uses-permission android:name="android.permission.QUERY_CLONED_APPS" /> + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> +</manifest> diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml index 55d6adf7..7bce113e 100644 --- a/java/res/layout/chooser_action_row.xml +++ b/java/res/layout/chooser_action_row.xml @@ -20,10 +20,9 @@ <com.android.intentresolver.widget.ScrollableActionRow android:id="@androidprv:id/chooser_action_row" - android:layout_width="match_parent" + android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center_horizontal" - android:gravity="center"/> + android:layout_gravity="center_horizontal" /> <View android:layout_width="match_parent" diff --git a/java/res/layout/chooser_action_view.xml b/java/res/layout/chooser_action_view.xml index ba9134cc..e17dce0e 100644 --- a/java/res/layout/chooser_action_view.xml +++ b/java/res/layout/chooser_action_view.xml @@ -18,7 +18,7 @@ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" style="?android:attr/borderlessButtonStyle" android:background="@drawable/chooser_action_button_bg" - android:paddingBottom="8dp" + android:paddingVertical="15dp" android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half" android:clickable="true" android:drawablePadding="6dp" diff --git a/java/res/layout/image_preview_loading_item.xml b/java/res/layout/image_preview_loading_item.xml new file mode 100644 index 00000000..85020e9a --- /dev/null +++ b/java/res/layout/image_preview_loading_item.xml @@ -0,0 +1,32 @@ +<!-- + ~ 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. + --> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="@dimen/chooser_preview_image_width" + android:layout_height="@dimen/chooser_preview_image_height_tall"> + + <ProgressBar + android:id="@+id/loading_indicator" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:indeterminate="true" + android:indeterminateTint="?androidprv:attr/materialColorPrimary" + android:indeterminateTintMode="src_in" /> + +</FrameLayout> diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml index 9a31a141..0ccab4c0 100644 --- a/java/res/values/styles.xml +++ b/java/res/values/styles.xml @@ -23,7 +23,7 @@ <item name="android:taskOpenExitAnimation">@anim/resolver_close_anim</item> </style> <style name="Theme.DeviceDefault.ResolverCommon" - parent="@android:style/Theme.DeviceDefault.DayNight"> + parent="@android:style/Theme.DeviceDefault.DayNight"> <item name="android:windowAnimationStyle">@style/ResolverAnimation</item> <item name="android:windowIsTranslucent">true</item> <item name="android:windowNoTitle">true</item> @@ -45,6 +45,7 @@ <style name="Theme.DeviceDefault.Chooser" parent="Theme.DeviceDefault.Resolver"> <item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item> <item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item> + <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> </style> <style name="TextAppearance.ChooserDefault" diff --git a/java/src/com/android/intentresolver/ApplicationComponentOwner.kt b/java/src/com/android/intentresolver/ApplicationComponentOwner.kt new file mode 100644 index 00000000..fb39814c --- /dev/null +++ b/java/src/com/android/intentresolver/ApplicationComponentOwner.kt @@ -0,0 +1,15 @@ +package com.android.intentresolver + +import com.android.intentresolver.dagger.ApplicationComponent + +/** + * Interface that should be implemented by the [Application][android.app.Application] object as the + * owner of the [ApplicationComponent]. + */ +interface ApplicationComponentOwner { + /** + * Invokes the given [action] when the [ApplicationComponent] has been created. If it has + * already been created, then it invokes [action] immediately. + */ + fun doWhenApplicationComponentReady(action: (ApplicationComponent) -> Unit) +} diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 06c7e8d7..a54e8c62 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -37,6 +37,7 @@ import android.view.View; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; +import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.widget.ActionRow; import com.android.internal.annotations.VisibleForTesting; @@ -97,7 +98,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final @Nullable ChooserAction mModifyShareAction; private final Consumer<Boolean> mExcludeSharedTextAction; private final Consumer</* @Nullable */ Integer> mFinishCallback; - private final ChooserActivityLogger mLogger; + private final EventLog mLogger; /** * @param context @@ -116,7 +117,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Context context, ChooserRequestParameters chooserRequest, ChooserIntegratedDeviceComponents integratedDeviceComponents, - ChooserActivityLogger logger, + EventLog logger, Consumer<Boolean> onUpdateSharedTextIsExcluded, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, @@ -152,7 +153,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio List<ChooserAction> customActions, @Nullable ChooserAction modifyShareAction, Consumer<Boolean> onUpdateSharedTextIsExcluded, - ChooserActivityLogger logger, + EventLog logger, Consumer</* @Nullable */ Integer> finishCallback) { mContext = context; mCopyButtonRunnable = copyButtonRunnable; @@ -208,7 +209,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio mModifyShareAction, mFinishCallback, () -> { - mLogger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE); + mLogger.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); }); } @@ -232,7 +233,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Intent targetIntent, String referrerPackageName, Consumer<Integer> finishCallback, - ChooserActivityLogger logger) { + EventLog logger) { final ClipData clipData; try { clipData = extractTextToCopy(targetIntent); @@ -248,7 +249,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Context.CLIPBOARD_SERVICE); clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); - logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); + logger.logActionSelected(EventLog.SELECTION_TYPE_COPY); finishCallback.accept(Activity.RESULT_OK); }; } @@ -327,10 +328,10 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio TargetInfo editSharingTarget, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, - ChooserActivityLogger logger) { + EventLog logger) { return () -> { // Log share completion via edit. - logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_EDIT); + logger.logActionSelected(EventLog.SELECTION_TYPE_EDIT); View firstImageView = null; try { diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 63ac6435..8edbba08 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -72,6 +72,7 @@ import android.view.animation.LinearInterpolator; import android.widget.TextView; import androidx.annotation.MainThread; +import androidx.lifecycle.HasDefaultViewModelProviderFactory; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -87,22 +88,28 @@ 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.dagger.InjectedViewModelFactory; +import com.android.intentresolver.dagger.ViewModelComponent; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.measurements.Tracer; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.ui.ChooserViewModel; import com.android.intentresolver.widget.ImagePreviewView; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import org.jetbrains.annotations.NotNull; + import java.io.File; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -118,13 +125,15 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; +import javax.inject.Inject; + /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. * */ public class ChooserActivity extends ResolverActivity implements - ResolverListAdapter.ResolverListCommunicator { + ResolverListAdapter.ResolverListCommunicator, HasDefaultViewModelProviderFactory { private static final String TAG = "ChooserActivity"; /** @@ -164,6 +173,11 @@ public class ChooserActivity extends ResolverActivity implements private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; + private ViewModelProvider.Factory mViewModelFactory; + private final ViewModelComponent.Builder mViewModelComponentBuilder; + + private ChooserViewModel mViewModel; + @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = { TARGET_TYPE_DEFAULT, TARGET_TYPE_CHOOSER_TARGET, @@ -191,7 +205,7 @@ public class ChooserActivity extends ResolverActivity implements private boolean mShouldDisplayLandscape; // statsd logger wrapper - protected ChooserActivityLogger mChooserActivityLogger; + protected EventLog mEventLog; private long mChooserShownTime; protected boolean mIsSuccessfullySelected; @@ -225,15 +239,32 @@ public class ChooserActivity extends ResolverActivity implements private boolean mExcludeSharedText = false; - public ChooserActivity() {} + @Inject + public ChooserActivity(ViewModelComponent.Builder builder) { + mViewModelComponentBuilder = builder; + } + + @NotNull + @Override + public final ViewModelProvider.Factory getDefaultViewModelProviderFactory() { + if (mViewModelFactory == null) { + mViewModelFactory = new InjectedViewModelFactory(mViewModelComponentBuilder, + getDefaultViewModelCreationExtras(), + getReferrer()); + } + return mViewModelFactory; + } @Override protected void onCreate(Bundle savedInstanceState) { + Log.d(TAG, "onCreate"); Tracer.INSTANCE.markLaunched(); final long intentReceivedTime = System.currentTimeMillis(); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); - getChooserActivityLogger().logSharesheetTriggered(); + getEventLog().logSharesheetTriggered(); + + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); mFeatureFlagRepository = createFeatureFlagRepository(); mIntegratedDeviceComponents = getIntegratedDeviceComponents(); @@ -251,7 +282,9 @@ public class ChooserActivity extends ResolverActivity implements return; } - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + // Note: Uses parent ViewModelProvider.Factory because RefinementManager is not injectable + mRefinementManager = new ViewModelProvider(this, super.getDefaultViewModelProviderFactory()) + .get(ChooserRefinementManager.class); mRefinementManager.getRefinementCompletion().observe(this, completion -> { if (completion.consume()) { @@ -273,7 +306,7 @@ public class ChooserActivity extends ResolverActivity implements BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) - .get(BasePreviewViewModel.class); + .get(PreviewViewModel.class); mChooserContentPreviewUi = new ChooserContentPreviewUi( getLifecycle(), previewViewModel.createOrReuseProvider(mChooserRequest), @@ -283,10 +316,6 @@ public class ChooserActivity extends ResolverActivity implements mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this)); - setAdditionalTargets(mChooserRequest.getAdditionalTargets()); - - setSafeForwardingMode(true); - mPinnedSharedPrefs = getPinnedSharedPrefs(this); mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); @@ -304,16 +333,18 @@ public class ChooserActivity extends ResolverActivity implements super.onCreate( savedInstanceState, mChooserRequest.getTargetIntent(), + mChooserRequest.getAdditionalTargets(), mChooserRequest.getTitle(), mChooserRequest.getDefaultTitleResource(), mChooserRequest.getInitialIntents(), - /* rList: List<ResolveInfo> = */ null, - /* supportsAlwaysUseOption = */ false, - new DefaultTargetDataLoader(this, getLifecycle(), false)); + /* resolutionList= */ null, + /* supportsAlwaysUseOption= */ false, + new DefaultTargetDataLoader(this, getLifecycle(), false), + /* safeForwardingMode= */ true); mChooserShownTime = System.currentTimeMillis(); final long systemCost = mChooserShownTime - intentReceivedTime; - getChooserActivityLogger().logChooserActivityShown( + getEventLog().logChooserActivityShown( isWorkProfile(), mChooserRequest.getTargetType(), systemCost); if (mResolverDrawerLayout != null) { @@ -322,7 +353,7 @@ public class ChooserActivity extends ResolverActivity implements mResolverDrawerLayout.setOnCollapsedChangedListener( isCollapsed -> { mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed); - getChooserActivityLogger().logSharesheetExpansionChanged(isCollapsed); + getEventLog().logSharesheetExpansionChanged(isCollapsed); }); } @@ -330,7 +361,7 @@ public class ChooserActivity extends ResolverActivity implements Log.d(TAG, "System Time Cost is " + systemCost); } - getChooserActivityLogger().logShareStarted( + getEventLog().logShareStarted( getReferrerPackageName(), mChooserRequest.getTargetType(), mChooserRequest.getCallerChooserTargets().size(), @@ -549,7 +580,7 @@ public class ChooserActivity extends ResolverActivity implements if (shouldShowStickyContentPreview() || mChooserMultiProfilePagerAdapter .getCurrentRootAdapter().getSystemRowCount() != 0) { - getChooserActivityLogger().logActionShareWithPreview( + getEventLog().logActionShareWithPreview( mChooserContentPreviewUi.getPreferredContentPreview()); } return postRebuildListInternal(rebuildCompleted); @@ -848,9 +879,7 @@ public class ChooserActivity extends ResolverActivity implements targetList, // Adding userHandle from ResolveInfo allows the app icon in Dialog Box to be // resolved correctly within the same tab. - getResolveInfoUserHandle( - targetInfo.getResolveInfo(), - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()), + targetInfo.getResolveInfo().userHandle, shortcutIdKey, shortcutTitle, isShortcutPinned, @@ -883,7 +912,7 @@ public class ChooserActivity extends ResolverActivity implements final long selectionCost = System.currentTimeMillis() - mChooserShownTime; - if (targetInfo.isMultiDisplayResolveInfo()) { + if ((targetInfo != null) && targetInfo.isMultiDisplayResolveInfo()) { MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; if (!mti.hasSelected()) { // Add userHandle based badge to the stackedAppDialogBox. @@ -891,20 +920,28 @@ public class ChooserActivity extends ResolverActivity implements getSupportFragmentManager(), mti, which, - getResolveInfoUserHandle( - targetInfo.getResolveInfo(), - mChooserMultiProfilePagerAdapter.getCurrentUserHandle())); + targetInfo.getResolveInfo().userHandle); return; } } super.startSelected(which, always, filtered); - if (currentListAdapter.getCount() > 0) { + // TODO: both of the conditions around this switch logic *should* be redundant, and + // can be removed if certain invariants can be guaranteed. In particular, it seems + // like targetInfo (from `ChooserListAdapter.targetInfoForPosition()`) is *probably* + // expected to be null only at out-of-bounds indexes where `getPositionTargetType()` + // returns TARGET_BAD; then the switch falls through to a default no-op, and we don't + // need to null-check targetInfo. We only need the null check if it's possible that + // the ChooserListAdapter contains null elements "in the middle" of its list data, + // such that they're classified as belonging to one of the real target types. That + // should probably never happen. But why would this method ever be invoked with a + // null target at all? Even an out-of-bounds index should never be "selected"... + if ((currentListAdapter.getCount() > 0) && (targetInfo != null)) { switch (currentListAdapter.getPositionTargetType(which)) { case ChooserListAdapter.TARGET_SERVICE: - getChooserActivityLogger().logShareTargetSelected( - ChooserActivityLogger.SELECTION_TYPE_SERVICE, + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_SERVICE, targetInfo.getResolveInfo().activityInfo.processName, which, /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), @@ -917,8 +954,8 @@ public class ChooserActivity extends ResolverActivity implements return; case ChooserListAdapter.TARGET_CALLER: case ChooserListAdapter.TARGET_STANDARD: - getChooserActivityLogger().logShareTargetSelected( - ChooserActivityLogger.SELECTION_TYPE_APP, + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_APP, targetInfo.getResolveInfo().activityInfo.processName, (which - currentListAdapter.getSurfacedTargetInfo().size()), /* directTargetAlsoRanked= */ -1, @@ -934,8 +971,8 @@ public class ChooserActivity extends ResolverActivity implements // they are from the alphabetical pool. // TODO: why do we log a different selection type if the -1 value already // designates the same condition? - getChooserActivityLogger().logShareTargetSelected( - ChooserActivityLogger.SELECTION_TYPE_STANDARD, + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_STANDARD, targetInfo.getResolveInfo().activityInfo.processName, /* value= */ -1, /* directTargetAlsoRanked= */ -1, @@ -987,7 +1024,7 @@ public class ChooserActivity extends ResolverActivity implements if (profileRecord == null) { return; } - getChooserActivityLogger().logDirectShareTargetReceived( + getEventLog().logDirectShareTargetReceived( MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime)); } @@ -1111,11 +1148,7 @@ public class ChooserActivity extends ResolverActivity implements // Adding two stage comparator, first stage compares using displayLabel, next stage // compares using resolveInfo.userHandle mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) - .thenComparingInt(displayResolveInfo -> - getResolveInfoUserHandle( - displayResolveInfo.getResolveInfo(), - // TODO: User resolveInfo.userHandle, once its available. - UserHandle.SYSTEM).getIdentifier()); + .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier()); } @Override @@ -1125,11 +1158,11 @@ public class ChooserActivity extends ResolverActivity implements } } - protected ChooserActivityLogger getChooserActivityLogger() { - if (mChooserActivityLogger == null) { - mChooserActivityLogger = new ChooserActivityLogger(); + protected EventLog getEventLog() { + if (mEventLog == null) { + mEventLog = new EventLog(); } - return mChooserActivityLogger; + return mEventLog; } public class ChooserListController extends ResolverListController { @@ -1255,7 +1288,7 @@ public class ChooserActivity extends ResolverActivity implements targetIntent, this, context.getPackageManager(), - getChooserActivityLogger(), + getEventLog(), chooserRequest, maxTargetsPerRow, initialIntentsUserSpace, @@ -1279,7 +1312,7 @@ public class ChooserActivity extends ResolverActivity implements AbstractResolverComparator resolverComparator; if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger(), + getReferrerPackageName(), appPredictor, userHandle, getEventLog(), getIntegratedDeviceComponents().getNearbySharingComponent()); } else { resolverComparator = @@ -1288,7 +1321,7 @@ public class ChooserActivity extends ResolverActivity implements getTargetIntent(), getReferrerPackageName(), null, - getChooserActivityLogger(), + getEventLog(), getResolverRankerServiceUserHandleList(userHandle), getIntegratedDeviceComponents().getNearbySharingComponent()); } @@ -1313,7 +1346,7 @@ public class ChooserActivity extends ResolverActivity implements this, mChooserRequest, mIntegratedDeviceComponents, - getChooserActivityLogger(), + getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, this::getFirstVisibleImgPreviewView, new ChooserActionFactory.ActionActivityStarter() { @@ -1528,7 +1561,7 @@ public class ChooserActivity extends ResolverActivity implements Log.d(TAG, "app target loading time " + duration + " ms"); } addCallerChooserTargets(); - getChooserActivityLogger().logSharesheetAppLoadComplete(); + getEventLog().logSharesheetAppLoadComplete(); maybeQueryAdditionalPostProcessingTargets(chooserListAdapter); mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET); } @@ -1575,7 +1608,7 @@ public class ChooserActivity extends ResolverActivity implements } logDirectShareTargetReceived(userHandle); sendVoiceChoicesIfNeeded(); - getChooserActivityLogger().logSharesheetDirectLoadComplete(); + getEventLog().logSharesheetDirectLoadComplete(); } private void setupScrollListener() { @@ -1881,7 +1914,7 @@ public class ChooserActivity extends ResolverActivity implements @Override protected void maybeLogProfileChange() { - getChooserActivityLogger().logSharesheetProfileChanged(); + getEventLog().logSharesheetProfileChanged(); } private static class ProfileRecord { diff --git a/java/src/com/android/intentresolver/ChooserActivityReEnabler.kt b/java/src/com/android/intentresolver/ChooserActivityReEnabler.kt deleted file mode 100644 index 3236c1be..00000000 --- a/java/src/com/android/intentresolver/ChooserActivityReEnabler.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.android.intentresolver - -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager - -/** - * Ensures that the unbundled version of [ChooserActivity] does not get stuck in a disabled state. - */ -class ChooserActivityReEnabler : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_BOOT_COMPLETED) { - context.packageManager.setComponentEnabledSetting( - CHOOSER_COMPONENT, - PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, - /* flags = */ 0, - ) - - // This only needs to be run once, so we disable ourself to avoid additional startup - // process on future boots - context.packageManager.setComponentEnabledSetting( - SELF_COMPONENT, - PackageManager.COMPONENT_ENABLED_STATE_DISABLED, - /* flags = */ 0, - ) - } - } - - companion object { - private const val CHOOSER_PACKAGE = "com.android.intentresolver" - private val CHOOSER_COMPONENT = - ComponentName(CHOOSER_PACKAGE, "$CHOOSER_PACKAGE.ChooserActivity") - private val SELF_COMPONENT = - ComponentName(CHOOSER_PACKAGE, "$CHOOSER_PACKAGE.ChooserActivityReEnabler") - } -} diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index b1fa16b0..e6d6dbf4 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -49,6 +49,7 @@ import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.logging.EventLog; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -80,7 +81,7 @@ public class ChooserListAdapter extends ResolverListAdapter { private final ChooserRequestParameters mChooserRequest; private final int mMaxRankedTargets; - private final ChooserActivityLogger mChooserActivityLogger; + private final EventLog mEventLog; private final Set<TargetInfo> mRequestedIcons = new HashSet<>(); @@ -139,7 +140,7 @@ public class ChooserListAdapter extends ResolverListAdapter { Intent targetIntent, ResolverListCommunicator resolverListCommunicator, PackageManager packageManager, - ChooserActivityLogger chooserActivityLogger, + EventLog eventLog, ChooserRequestParameters chooserRequest, int maxRankedTargets, UserHandle initialIntentsUserSpace, @@ -165,7 +166,7 @@ public class ChooserListAdapter extends ResolverListAdapter { mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); mTargetDataLoader = targetDataLoader; createPlaceHolders(); - mChooserActivityLogger = chooserActivityLogger; + mEventLog = eventLog; mShortcutSelectionLogic = new ShortcutSelectionLogic( context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp), DeviceConfig.getBoolean( @@ -384,8 +385,7 @@ public class ChooserListAdapter extends ResolverListAdapter { .collect(Collectors.groupingBy(target -> target.getResolvedComponentName().getPackageName() + "#" + target.getDisplayLabel() - + '#' + ResolverActivity.getResolveInfoUserHandle( - target.getResolveInfo(), getUserHandle()).getIdentifier() + + '#' + target.getResolveInfo().userHandle.getIdentifier() )) .values() .stream() @@ -634,7 +634,7 @@ public class ChooserListAdapter extends ResolverListAdapter { mServiceTargets.removeIf(o -> o.isPlaceHolderTargetInfo()); if (mServiceTargets.isEmpty()) { mServiceTargets.add(NotSelectableTargetInfo.newEmptyTargetInfo()); - mChooserActivityLogger.logSharesheetEmptyDirectShareRow(); + mEventLog.logSharesheetEmptyDirectShareRow(); } notifyDataSetChanged(); } diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index 5e8945f1..d69a6c71 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -57,6 +57,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import javax.inject.Inject; + /** * This is used in conjunction with * {@link DevicePolicyManager#addCrossProfileIntentFilter} to enable intents to @@ -84,6 +86,11 @@ public class IntentForwarderActivity extends Activity { private MetricsLogger mMetricsLogger; protected ExecutorService mExecutorService; + @Inject + public IntentForwarderActivity() { + super(); + } + @Override protected void onDestroy() { super.onDestroy(); diff --git a/java/src/com/android/intentresolver/IntentResolverApplication.kt b/java/src/com/android/intentresolver/IntentResolverApplication.kt new file mode 100644 index 00000000..61df7fff --- /dev/null +++ b/java/src/com/android/intentresolver/IntentResolverApplication.kt @@ -0,0 +1,30 @@ +package com.android.intentresolver + +import android.app.Application +import com.android.intentresolver.dagger.ApplicationComponent +import com.android.intentresolver.dagger.DaggerApplicationComponent + +/** [Application] that maintains the [ApplicationComponent]. */ +open class IntentResolverApplication : Application(), ApplicationComponentOwner { + + private lateinit var applicationComponent: ApplicationComponent + + private val pendingDaggerActions = mutableSetOf<(ApplicationComponent) -> Unit>() + + open fun createApplicationComponentBuilder() = DaggerApplicationComponent.builder() + + override fun onCreate() { + super.onCreate() + applicationComponent = createApplicationComponentBuilder().application(this).build() + pendingDaggerActions.forEach { it.invoke(applicationComponent) } + pendingDaggerActions.clear() + } + + override fun doWhenApplicationComponentReady(action: (ApplicationComponent) -> Unit) { + if (this::applicationComponent.isInitialized) { + action.invoke(applicationComponent) + } else { + pendingDaggerActions.add(action) + } + } +} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 57871532..252d0a41 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -27,6 +27,7 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERS import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; +import static android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.content.PermissionChecker.PID_UNKNOWN; import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; @@ -119,12 +120,15 @@ import com.android.internal.util.LatencyTracker; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.function.Supplier; +import javax.inject.Inject; + /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is * *not* the resolver that is actually triggered by the system right now (you want @@ -135,6 +139,7 @@ import java.util.function.Supplier; public class ResolverActivity extends FragmentActivity implements ResolverListAdapter.ResolverListCommunicator { + @Inject public ResolverActivity() { mIsIntentPicker = getClass().equals(ResolverActivity.class); } @@ -143,7 +148,14 @@ public class ResolverActivity extends FragmentActivity implements mIsIntentPicker = isIntentPicker; } + /** + * Whether to enable a launch mode that is safe to use when forwarding intents received from + * applications and running in system processes. This mode uses Activity.startActivityAsCaller + * instead of the normal Activity.startActivity for launching the activity selected + * by the user. + */ private boolean mSafeForwardingMode; + private Button mAlwaysButton; private Button mOnceButton; protected View mProfileView; @@ -332,38 +344,55 @@ public class ResolverActivity extends FragmentActivity implements mResolvingHome = true; } - setSafeForwardingMode(true); - - onCreate(savedInstanceState, intent, null, 0, null, null, true, createIconLoader()); + onCreate( + savedInstanceState, + intent, + /* additionalTargets= */ null, + /* title= */ null, + /* defaultTitleRes= */ 0, + /* initialIntents= */ null, + /* resolutionList= */ null, + /* supportsAlwaysUseOption= */ true, + createIconLoader(), + /* safeForwardingMode= */ true); } /** * Compatibility version for other bundled services that use this overload without * a default title resource */ - protected void onCreate(Bundle savedInstanceState, Intent intent, - CharSequence title, Intent[] initialIntents, - List<ResolveInfo> rList, boolean supportsAlwaysUseOption) { + protected void onCreate( + Bundle savedInstanceState, + Intent intent, + CharSequence title, + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean supportsAlwaysUseOption, + boolean safeForwardingMode) { onCreate( savedInstanceState, intent, + null, title, 0, initialIntents, - rList, + resolutionList, supportsAlwaysUseOption, - createIconLoader()); + createIconLoader(), + safeForwardingMode); } protected void onCreate( Bundle savedInstanceState, Intent intent, + Intent[] additionalTargets, CharSequence title, int defaultTitleRes, Intent[] initialIntents, - List<ResolveInfo> rList, + List<ResolveInfo> resolutionList, boolean supportsAlwaysUseOption, - TargetDataLoader targetDataLoader) { + TargetDataLoader targetDataLoader, + boolean safeForwardingMode) { setTheme(appliedThemeResId()); super.onCreate(savedInstanceState); @@ -381,12 +410,17 @@ public class ResolverActivity extends FragmentActivity implements mReferrerPackage = getReferrerPackageName(); - // Add our initial intent as the first item, regardless of what else has already been added. + // The initial intent must come before any other targets that are to be added. mIntents.add(0, new Intent(intent)); + if (additionalTargets != null) { + Collections.addAll(mIntents, additionalTargets); + } + mTitle = title; mDefaultTitleResId = defaultTitleRes; mSupportsAlwaysUseOption = supportsAlwaysUseOption; + mSafeForwardingMode = safeForwardingMode; // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always @@ -399,7 +433,7 @@ public class ResolverActivity extends FragmentActivity implements boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() && !shouldShowTabs() && !hasCloneProfile(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - initialIntents, rList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed, targetDataLoader); if (configureContentView(targetDataLoader)) { return; } @@ -455,17 +489,17 @@ public class ResolverActivity extends FragmentActivity implements protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, - List<ResolveInfo> rList, + List<ResolveInfo> resolutionList, boolean filterLastUsed, TargetDataLoader targetDataLoader) { AbstractMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; if (shouldShowTabs()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed, targetDataLoader); } else { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed, targetDataLoader); } return resolverMultiProfilePagerAdapter; } @@ -1043,7 +1077,7 @@ public class ResolverActivity extends FragmentActivity implements Context context, List<Intent> payloadIntents, Intent[] initialIntents, - List<ResolveInfo> rList, + List<ResolveInfo> resolutionList, boolean filterLastUsed, UserHandle userHandle, TargetDataLoader targetDataLoader) { @@ -1054,7 +1088,7 @@ public class ResolverActivity extends FragmentActivity implements context, payloadIntents, initialIntents, - rList, + resolutionList, filterLastUsed, createListController(userHandle), userHandle, @@ -1127,6 +1161,12 @@ public class ResolverActivity extends FragmentActivity implements // flag set, we are now losing it. That should be a very rare case // and we can live with this. intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + + // If FLAG_ACTIVITY_LAUNCH_ADJACENT was set, ResolverActivity was opened in the alternate + // side, which means we want to open the target app on the same side as ResolverActivity. + if ((intent.getFlags() & FLAG_ACTIVITY_LAUNCH_ADJACENT) != 0) { + intent.setFlags(intent.getFlags() & ~FLAG_ACTIVITY_LAUNCH_ADJACENT); + } return intent; } @@ -1142,14 +1182,14 @@ public class ResolverActivity extends FragmentActivity implements private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, - List<ResolveInfo> rList, + List<ResolveInfo> resolutionList, boolean filterLastUsed, TargetDataLoader targetDataLoader) { ResolverListAdapter adapter = createResolverListAdapter( /* context */ this, /* payloadIntents */ mIntents, initialIntents, - rList, + resolutionList, filterLastUsed, /* userHandle */ getPersonalProfileUserHandle(), targetDataLoader); @@ -1170,7 +1210,7 @@ public class ResolverActivity extends FragmentActivity implements private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, - List<ResolveInfo> rList, + List<ResolveInfo> resolutionList, boolean filterLastUsed, TargetDataLoader targetDataLoader) { // In the edge case when we have 0 apps in the current profile and >1 apps in the other, @@ -1197,7 +1237,7 @@ public class ResolverActivity extends FragmentActivity implements /* context */ this, /* payloadIntents */ mIntents, selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - rList, + resolutionList, (filterLastUsed && UserHandle.myUserId() == getPersonalProfileUserHandle().getIdentifier()), /* userHandle */ getPersonalProfileUserHandle(), @@ -1207,7 +1247,7 @@ public class ResolverActivity extends FragmentActivity implements /* context */ this, /* payloadIntents */ mIntents, selectedProfile == PROFILE_WORK ? initialIntents : null, - rList, + resolutionList, (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), /* userHandle */ workProfileUserHandle, @@ -1365,14 +1405,6 @@ public class ResolverActivity extends FragmentActivity implements return new Option(target.getDisplayLabel(), index); } - protected final void setAdditionalTargets(Intent[] intents) { - if (intents != null) { - for (Intent intent : intents) { - mIntents.add(intent); - } - } - } - public final Intent getTargetIntent() { return mIntents.isEmpty() ? null : mIntents.get(0); } @@ -1433,22 +1465,6 @@ public class ResolverActivity extends FragmentActivity implements () -> getString(R.string.forward_intent_to_work)); } - /** - * Turn on launch mode that is safe to use when forwarding intents received from - * applications and running in system processes. This mode uses Activity.startActivityAsCaller - * instead of the normal Activity.startActivity for launching the activity selected - * by the user. - * - * <p>This mode is set to true by default if the activity is initialized through - * {@link #onCreate(android.os.Bundle)}. If a subclass calls one of the other onCreate - * methods, it is set to false by default. You must set it before calling one of the - * more detailed onCreate methods, so that it will be set correctly in the case where - * there is only one intent to resolve and it is thus started immediately.</p> - */ - public final void setSafeForwardingMode(boolean safeForwarding) { - mSafeForwardingMode = safeForwarding; - } - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { final ActionTitle title = mResolvingHome ? ActionTitle.HOME @@ -1649,10 +1665,9 @@ public class ResolverActivity extends FragmentActivity implements /** Start the activity specified by the {@link TargetInfo}.*/ public final void safelyStartActivity(TargetInfo cti) { // In case cloned apps are present, we would want to start those apps in cloned user - // space, which will not be same as adaptor's userHandle. resolveInfo.userHandle + // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle // identifies the correct user space in such cases. - UserHandle activityUserHandle = getResolveInfoUserHandle( - cti.getResolveInfo(), mMultiProfilePagerAdapter.getCurrentUserHandle()); + UserHandle activityUserHandle = cti.getResolveInfo().userHandle; safelyStartActivityAsUser(cti, activityUserHandle, null); } @@ -2267,11 +2282,7 @@ public class ResolverActivity extends FragmentActivity implements && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName) // Comparing against resolveInfo.userHandle in case cloned apps are present, // as they will have the same activityInfo. - && Objects.equals( - getResolveInfoUserHandle(lhs, - mMultiProfilePagerAdapter.getActiveListAdapter().getUserHandle()), - getResolveInfoUserHandle(rhs, - mMultiProfilePagerAdapter.getActiveListAdapter().getUserHandle())); + && Objects.equals(lhs.userHandle, rhs.userHandle); } private boolean inactiveListAdapterHasItems() { @@ -2409,13 +2420,4 @@ public class ResolverActivity extends FragmentActivity implements } return userList; } - - /** - * This function is temporary in nature, and its usages will be replaced with just - * resolveInfo.userHandle, once it is available, once sharesheet is stable. - */ - public static UserHandle getResolveInfoUserHandle(ResolveInfo resolveInfo, - UserHandle predictedHandle) { - return resolveInfo.userHandle; - } } diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index e8367c4e..d279f11f 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -16,6 +16,8 @@ package com.android.intentresolver.contentpreview; +import static androidx.lifecycle.LifecycleKt.getCoroutineScope; + import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; @@ -150,26 +152,31 @@ public final class ChooserContentPreviewUi { isSingleImageShare, previewData.getUriCount(), targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + targetIntent.getType(), actionFactory, imageLoader, typeClassifier, headlineGenerator); if (previewData.getUriCount() > 0) { - previewData.getFileMetadataForImagePreview( - mLifecycle, previewUi::updatePreviewMetadata); + JavaFlowHelper.collectToList( + getCoroutineScope(mLifecycle), + previewData.getImagePreviewFileInfoFlow(), + previewUi::updatePreviewMetadata); } return previewUi; } - UnifiedContentPreviewUi unifiedContentPreviewUi = new UnifiedContentPreviewUi( + return new UnifiedContentPreviewUi( + getCoroutineScope(mLifecycle), isSingleImageShare, + targetIntent.getType(), actionFactory, imageLoader, typeClassifier, transitionElementStatusCallback, + previewData.getImagePreviewFileInfoFlow(), + previewData.getUriCount(), headlineGenerator); - previewData.getFileMetadataForImagePreview(mLifecycle, unifiedContentPreviewUi::setFiles); - return unifiedContentPreviewUi; } public int getPreferredContentPreview() { diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 07071236..2d81794e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -85,7 +85,7 @@ abstract class ContentPreviewUi { } } - protected static ScrollableImagePreviewView.PreviewType getPreviewType( + static ScrollableImagePreviewView.PreviewType getPreviewType( MimeTypeClassifier typeClassifier, String mimeType) { if (mimeType == null) { return ScrollableImagePreviewView.PreviewType.File; diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 35990990..6e1212e9 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -49,6 +49,8 @@ import java.util.function.Consumer; */ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private final Lifecycle mLifecycle; + @Nullable + private final String mIntentMimeType; private final CharSequence mText; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; @@ -70,15 +72,17 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { boolean isSingleImage, int fileCount, CharSequence text, + @Nullable String intentMimeType, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, HeadlineGenerator headlineGenerator) { - mLifecycle = lifecycle; if (isSingleImage && fileCount != 1) { throw new IllegalArgumentException( "fileCount = " + fileCount + " and isSingleImage = true"); } + mLifecycle = lifecycle; + mIntentMimeType = intentMimeType; mFileCount = fileCount; mIsSingleImage = isSingleImage; mText = text; @@ -127,18 +131,25 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { List<ActionRow.Action> actions = mActionFactory.createCustomActions(); actionRow.setActions(actions); + if (!mIsSingleImage) { + mContentPreviewView.requireViewById(R.id.image_view).setVisibility(View.GONE); + } + prepareTextPreview(mContentPreviewView, mActionFactory); if (mIsMetadataUpdated) { updateUiWithMetadata(mContentPreviewView); - } else if (!mIsSingleImage) { - mContentPreviewView.requireViewById(R.id.image_view).setVisibility(View.GONE); + } else { + updateHeadline( + mContentPreviewView, + mFileCount, + mTypeClassifier.isImageType(mIntentMimeType), + mTypeClassifier.isVideoType(mIntentMimeType)); } return mContentPreviewView; } private void updateUiWithMetadata(ViewGroup contentPreviewView) { - prepareTextPreview(contentPreviewView, mActionFactory); - updateHeadline(contentPreviewView); + updateHeadline(contentPreviewView, mFileCount, mAllImages, mAllVideos); ImageView imagePreview = mContentPreviewView.requireViewById(R.id.image_view); if (mIsSingleImage && mFirstFilePreviewUri != null) { @@ -157,24 +168,25 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } } - private void updateHeadline(ViewGroup contentPreview) { + private void updateHeadline( + ViewGroup contentPreview, int fileCount, boolean allImages, boolean allVideos) { CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action); String headline; if (includeText.getVisibility() == View.VISIBLE && includeText.isChecked()) { - if (mAllImages) { - headline = mHeadlineGenerator.getImagesWithTextHeadline(mText, mFileCount); - } else if (mAllVideos) { - headline = mHeadlineGenerator.getVideosWithTextHeadline(mText, mFileCount); + if (allImages) { + headline = mHeadlineGenerator.getImagesWithTextHeadline(mText, fileCount); + } else if (allVideos) { + headline = mHeadlineGenerator.getVideosWithTextHeadline(mText, fileCount); } else { - headline = mHeadlineGenerator.getFilesWithTextHeadline(mText, mFileCount); + headline = mHeadlineGenerator.getFilesWithTextHeadline(mText, fileCount); } } else { - if (mAllImages) { - headline = mHeadlineGenerator.getImagesHeadline(mFileCount); - } else if (mAllVideos) { - headline = mHeadlineGenerator.getVideosHeadline(mFileCount); + if (allImages) { + headline = mHeadlineGenerator.getImagesHeadline(fileCount); + } else if (allVideos) { + headline = mHeadlineGenerator.getVideosHeadline(fileCount); } else { - headline = mHeadlineGenerator.getFilesHeadline(mFileCount); + headline = mHeadlineGenerator.getFilesHeadline(fileCount); } } @@ -201,7 +213,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { textView.setText(getNoTextString(contentPreview.getResources())); } shareTextAction.accept(!isChecked); - updateHeadline(contentPreview); + updateHeadline(contentPreview, mFileCount, mAllImages, mAllVideos); }); if (SHOW_TOGGLE_CHECKMARK) { includeText.setVisibility(View.VISIBLE); diff --git a/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt b/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt new file mode 100644 index 00000000..b29c5774 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("JavaFlowHelper") + +package com.android.intentresolver.contentpreview + +import com.android.intentresolver.widget.ScrollableImagePreviewView.Preview +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch + +internal fun mapFileIntoToPreview( + flow: Flow<FileInfo>, + typeClassifier: MimeTypeClassifier, + editAction: Runnable? +): Flow<Preview> = + flow + .filter { it.previewUri != null } + .map { fileInfo -> + Preview( + ContentPreviewUi.getPreviewType(typeClassifier, fileInfo.mimeType), + requireNotNull(fileInfo.previewUri), + editAction + ) + } + +internal fun <T> collectToList( + clientScope: CoroutineScope, + flow: Flow<T>, + callback: Consumer<List<T>> +) { + clientScope.launch { callback.accept(flow.toList()) } +} diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 8ab3a272..9f1cc6c1 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -38,14 +38,18 @@ import com.android.intentresolver.measurements.runTracing import com.android.intentresolver.util.ownedByCurrentUser import java.util.concurrent.atomic.AtomicInteger import java.util.function.Consumer +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.take import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** @@ -68,31 +72,45 @@ private const val TIMEOUT_MS = 1_000L */ @OpenForTesting open class PreviewDataProvider -@VisibleForTesting +@JvmOverloads constructor( + private val scope: CoroutineScope, private val targetIntent: Intent, private val contentResolver: ContentInterface, - private val typeClassifier: MimeTypeClassifier, - private val dispatcher: CoroutineDispatcher, + private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier, ) { - constructor( - targetIntent: Intent, - contentResolver: ContentInterface, - ) : this( - targetIntent, - contentResolver, - DefaultMimeTypeClassifier, - Dispatchers.IO, - ) private val records = targetIntent.contentUris.map { UriRecord(it) } + private val fileInfoSharedFlow: SharedFlow<FileInfo> by lazy { + // Alternatively, we could just use [shareIn()] on a [flow] -- and it would be, arguably, + // cleaner -- but we'd lost the ability to trace the traverse as [runTracing] does not + // generally work over suspend function invocations. + MutableSharedFlow<FileInfo>(replay = records.size).apply { + scope.launch { + runTracing("image-preview-metadata") { + for (record in records) { + tryEmit(FileInfo.Builder(record.uri).readFromRecord(record).build()) + } + } + } + } + } + /** returns number of shared URIs, see [Intent.EXTRA_STREAM] */ @get:OpenForTesting open val uriCount: Int get() = records.size /** + * Returns a [Flow] of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] and + * [FileInfo.previewUri] set (a data projection tailored for the image preview UI). + */ + @get:OpenForTesting + open val imagePreviewFileInfoFlow: Flow<FileInfo> + get() = fileInfoSharedFlow.take(records.size) + + /** * Preview type to use. The type is determined asynchronously with a timeout; the fall-back * values is [ContentPreviewType.CONTENT_PREVIEW_FILE] */ @@ -107,10 +125,18 @@ constructor( if (!targetIntent.isSend || records.isEmpty()) { CONTENT_PREVIEW_TEXT } else { - runBlocking(dispatcher) { - withTimeoutOrNull(TIMEOUT_MS) { - loadPreviewType() - } ?: CONTENT_PREVIEW_FILE + try { + runBlocking(scope.coroutineContext) { + withTimeoutOrNull(TIMEOUT_MS) { scope.async { loadPreviewType() }.await() } + ?: CONTENT_PREVIEW_FILE + } + } catch (e: CancellationException) { + Log.w( + ContentPreviewUi.TAG, + "An attempt to read preview type from a cancelled scope", + e + ) + CONTENT_PREVIEW_FILE } } } @@ -123,46 +149,24 @@ constructor( open val firstFileInfo: FileInfo? by lazy { runTracing("first-uri-metadata") { records.firstOrNull()?.let { record -> - runBlocking(dispatcher) { - val builder = FileInfo.Builder(record.uri) - withTimeoutOrNull(TIMEOUT_MS) { - builder.readFromRecord(record) + val builder = FileInfo.Builder(record.uri) + try { + runBlocking(scope.coroutineContext) { + withTimeoutOrNull(TIMEOUT_MS) { + scope.async { builder.readFromRecord(record) }.await() + } } - builder.build() - } - } - } - } - - /** - * Returns a collection of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] - * and [FileInfo.previewUri] set (a data projection tailored for the image preview UI). - */ - @OpenForTesting - open fun getFileMetadataForImagePreview( - callerLifecycle: Lifecycle, - callback: Consumer<List<FileInfo>>, - ) { - callerLifecycle.coroutineScope.launch { - val result = withContext(dispatcher) { - getFileMetadataForImagePreview() - } - callback.accept(result) - } - } - - private fun getFileMetadataForImagePreview(): List<FileInfo> = - runTracing("image-preview-metadata") { - ArrayList<FileInfo>(records.size).also { result -> - for (record in records) { - result.add( - FileInfo.Builder(record.uri) - .readFromRecord(record) - .build() + } catch (e: CancellationException) { + Log.w( + ContentPreviewUi.TAG, + "An attempt to read first file info from a cancelled scope", + e ) } + builder.build() } } + } private fun FileInfo.Builder.readFromRecord(record: UriRecord): FileInfo.Builder { withMimeType(record.mimeType) @@ -186,9 +190,7 @@ constructor( throw IndexOutOfBoundsException("There are no shared URIs") } callerLifecycle.coroutineScope.launch { - val result = withContext(dispatcher) { - getFirstFileName() - } + val result = scope.async { getFirstFileName() }.await() callback.accept(result) } } @@ -237,8 +239,7 @@ constructor( } resultDeferred.complete(CONTENT_PREVIEW_FILE) } - resultDeferred.await() - .also { job.cancel() } + resultDeferred.await().also { job.cancel() } } } @@ -251,8 +252,8 @@ constructor( val isImageType: Boolean get() = typeClassifier.isImageType(mimeType) val supportsImageType: Boolean by lazy { - contentResolver.getStreamTypesSafe(uri) - ?.firstOrNull(typeClassifier::isImageType) != null + contentResolver.getStreamTypesSafe(uri)?.firstOrNull(typeClassifier::isImageType) != + null } val supportsThumbnail: Boolean get() = query.supportsThumbnail @@ -264,9 +265,8 @@ constructor( private val query by lazy { readQueryResult() } private fun readQueryResult(): QueryResult { - val cursor = contentResolver.querySafe(uri) - ?.takeIf { it.moveToFirst() } - ?: return QueryResult() + val cursor = + contentResolver.querySafe(uri)?.takeIf { it.moveToFirst() } ?: return QueryResult() var flagColIdx = -1 var displayIconUriColIdx = -1 diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 331b0cb6..6013f5a0 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -25,11 +25,15 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.ChooserRequestParameters import com.android.intentresolver.R +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.plus /** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */ -class PreviewViewModel(private val application: Application) : BasePreviewViewModel() { +class PreviewViewModel( + private val application: Application, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : BasePreviewViewModel() { private var previewDataProvider: PreviewDataProvider? = null private var imageLoader: ImagePreviewImageLoader? = null @@ -38,15 +42,18 @@ class PreviewViewModel(private val application: Application) : BasePreviewViewMo chooserRequest: ChooserRequestParameters ): PreviewDataProvider = previewDataProvider - ?: PreviewDataProvider(chooserRequest.targetIntent, application.contentResolver).also { - previewDataProvider = it - } + ?: PreviewDataProvider( + viewModelScope + dispatcher, + chooserRequest.targetIntent, + application.contentResolver + ) + .also { previewDataProvider = it } @MainThread override fun createOrReuseImageLoader(): ImageLoader = imageLoader ?: ImagePreviewImageLoader( - viewModelScope + Dispatchers.IO, + viewModelScope + dispatcher, thumbnailSize = application.resources.getDimensionPixelSize( R.dimen.chooser_preview_image_max_dimen diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 6385f2b6..8e635aba 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -31,35 +31,50 @@ import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import com.android.intentresolver.widget.ScrollableImagePreviewView; -import java.util.ArrayList; import java.util.List; import java.util.Objects; +import kotlinx.coroutines.CoroutineScope; +import kotlinx.coroutines.flow.Flow; + class UnifiedContentPreviewUi extends ContentPreviewUi { private final boolean mShowEditAction; + @Nullable + private final String mIntentMimeType; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final HeadlineGenerator mHeadlineGenerator; + private final Flow<FileInfo> mFileInfoFlow; + private final int mItemCount; @Nullable private List<FileInfo> mFiles; @Nullable private ViewGroup mContentPreviewView; UnifiedContentPreviewUi( + CoroutineScope scope, boolean isSingleImage, + @Nullable String intentMimeType, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, TransitionElementStatusCallback transitionElementStatusCallback, + Flow<FileInfo> fileInfoFlow, + int itemCount, HeadlineGenerator headlineGenerator) { mShowEditAction = isSingleImage; + mIntentMimeType = intentMimeType; mActionFactory = actionFactory; mImageLoader = imageLoader; mTypeClassifier = typeClassifier; mTransitionElementStatusCallback = transitionElementStatusCallback; + mFileInfoFlow = fileInfoFlow; + mItemCount = itemCount; mHeadlineGenerator = headlineGenerator; + + JavaFlowHelper.collectToList(scope, fileInfoFlow, this::setFiles); } @Override @@ -74,7 +89,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return layout; } - public void setFiles(List<FileInfo> files) { + private void setFiles(List<FileInfo> files) { mImageLoader.prePopulate(files.stream() .map(FileInfo::getPreviewUri) .filter(Objects::nonNull) @@ -96,11 +111,25 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { ScrollableImagePreviewView imagePreview = mContentPreviewView.requireViewById(R.id.scrollable_image_preview); + imagePreview.setImageLoader(mImageLoader); imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE)); imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); + imagePreview.setPreviews( + JavaFlowHelper.mapFileIntoToPreview( + mFileInfoFlow, + mTypeClassifier, + mShowEditAction ? mActionFactory.getEditButtonRunnable() : null), + mItemCount); if (mFiles != null) { updatePreviewWithFiles(mContentPreviewView, mFiles); + } else { + displayHeadline( + mContentPreviewView, + mItemCount, + mTypeClassifier.isImageType(mIntentMimeType), + mTypeClassifier.isVideoType(mIntentMimeType)); + imagePreview.setLoading(mItemCount); } return mContentPreviewView; @@ -120,7 +149,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return; } - List<ScrollableImagePreviewView.Preview> previews = new ArrayList<>(); boolean allImages = true; boolean allVideos = true; for (FileInfo fileInfo : files) { @@ -128,24 +156,19 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { getPreviewType(mTypeClassifier, fileInfo.getMimeType()); allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; - - if (fileInfo.getPreviewUri() != null) { - Runnable editAction = - mShowEditAction ? mActionFactory.getEditButtonRunnable() : null; - previews.add( - new ScrollableImagePreviewView.Preview( - previewType, fileInfo.getPreviewUri(), editAction)); - } } - imagePreview.setPreviews(previews, count - previews.size(), mImageLoader); + displayHeadline(contentPreviewView, count, allImages, allVideos); + } + private void displayHeadline( + ViewGroup layout, int count, boolean allImages, boolean allVideos) { if (allImages) { - displayHeadline(contentPreviewView, mHeadlineGenerator.getImagesHeadline(count)); + displayHeadline(layout, mHeadlineGenerator.getImagesHeadline(count)); } else if (allVideos) { - displayHeadline(contentPreviewView, mHeadlineGenerator.getVideosHeadline(count)); + displayHeadline(layout, mHeadlineGenerator.getVideosHeadline(count)); } else { - displayHeadline(contentPreviewView, mHeadlineGenerator.getFilesHeadline(count)); + displayHeadline(layout, mHeadlineGenerator.getFilesHeadline(count)); } } } diff --git a/java/src/com/android/intentresolver/dagger/ActivityBinderModule.kt b/java/src/com/android/intentresolver/dagger/ActivityBinderModule.kt new file mode 100644 index 00000000..7c997ef7 --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/ActivityBinderModule.kt @@ -0,0 +1,33 @@ +package com.android.intentresolver.dagger + +import android.app.Activity +import com.android.intentresolver.ChooserActivity +import com.android.intentresolver.IntentForwarderActivity +import com.android.intentresolver.ResolverActivity +import dagger.Binds +import dagger.Module +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +/** Injection instructions for injectable [Activities][Activity]. */ +@Module +interface ActivityBinderModule { + + @Binds + @IntoMap + @ClassKey(ChooserActivity::class) + @ActivityScope + fun bindChooserActivity(activity: ChooserActivity): Activity + + @Binds + @IntoMap + @ClassKey(ResolverActivity::class) + @ActivityScope + fun bindResolverActivity(activity: ResolverActivity): Activity + + @Binds + @IntoMap + @ClassKey(IntentForwarderActivity::class) + @ActivityScope + fun bindIntentForwarderActivity(activity: IntentForwarderActivity): Activity +} diff --git a/java/src/com/android/intentresolver/dagger/ActivityComponent.kt b/java/src/com/android/intentresolver/dagger/ActivityComponent.kt new file mode 100644 index 00000000..bf5ff761 --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/ActivityComponent.kt @@ -0,0 +1,21 @@ +package com.android.intentresolver.dagger + +import android.app.Activity +import dagger.Subcomponent +import javax.inject.Provider +import javax.inject.Scope + +@MustBeDocumented @Retention(AnnotationRetention.RUNTIME) @Scope annotation class ActivityScope + +/** Subcomponent for injections across the life of an Activity. */ +@ActivityScope +@Subcomponent(modules = [ActivityModule::class, ActivityBinderModule::class]) +interface ActivityComponent { + + @Subcomponent.Factory + interface Factory { + fun create(): ActivityComponent + } + + fun activities(): Map<Class<*>, @JvmSuppressWildcards Provider<Activity>> +} diff --git a/java/src/com/android/intentresolver/dagger/ActivityModule.kt b/java/src/com/android/intentresolver/dagger/ActivityModule.kt new file mode 100644 index 00000000..f6a2229d --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/ActivityModule.kt @@ -0,0 +1,6 @@ +package com.android.intentresolver.dagger + +import dagger.Module + +/** Bindings provided to [@ActivityScope][ActivityScope]. */ +@Module interface ActivityModule diff --git a/java/src/com/android/intentresolver/dagger/ApplicationComponent.kt b/java/src/com/android/intentresolver/dagger/ApplicationComponent.kt new file mode 100644 index 00000000..9fc57712 --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/ApplicationComponent.kt @@ -0,0 +1,21 @@ +package com.android.intentresolver.dagger + +import android.app.Application +import dagger.BindsInstance +import dagger.Component +import javax.inject.Singleton + +/** Top level component for injections across the life of the process. */ +@Singleton +@Component(modules = [ApplicationModule::class]) +interface ApplicationComponent { + + @Component.Builder + interface Builder { + @BindsInstance fun application(application: Application): Builder + + fun build(): ApplicationComponent + } + + fun inject(appComponentFactory: InjectedAppComponentFactory) +} diff --git a/java/src/com/android/intentresolver/dagger/ApplicationModule.kt b/java/src/com/android/intentresolver/dagger/ApplicationModule.kt new file mode 100644 index 00000000..4986d7e1 --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/ApplicationModule.kt @@ -0,0 +1,29 @@ +package com.android.intentresolver.dagger + +import android.app.Application +import android.content.Context +import com.android.intentresolver.dagger.qualifiers.App +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +/** + * Bindings provided to [ApplicationComponent] and children. + * + * These are all @Singleton scope, one for the duration of the process. + */ +@Module( + subcomponents = [ActivityComponent::class, ViewModelComponent::class], + includes = [ReceiverBinderModule::class, CoroutinesModule::class], +) +interface ApplicationModule { + + companion object { + + @JvmStatic + @Provides + @Singleton + @App + fun applicationContext(app: Application): Context = app.applicationContext + } +} diff --git a/java/src/com/android/intentresolver/dagger/CoroutinesModule.kt b/java/src/com/android/intentresolver/dagger/CoroutinesModule.kt new file mode 100644 index 00000000..5fda2c30 --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/CoroutinesModule.kt @@ -0,0 +1,51 @@ +/* + * 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.dagger + +import com.android.intentresolver.dagger.qualifiers.Background +import com.android.intentresolver.dagger.qualifiers.Main +import dagger.Module +import dagger.Provides +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +interface CoroutinesModule { + companion object { + @JvmStatic + @Provides + @Singleton + @Main + fun mainDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate + + @JvmStatic + @Provides + @Singleton + @Main + fun mainCoroutineScope(@Main mainDispatcher: CoroutineDispatcher) = + CoroutineScope(SupervisorJob() + mainDispatcher) + + @JvmStatic + @Provides + @Singleton + @Background + fun backgroundDispatcher(): CoroutineDispatcher = Dispatchers.IO + } +} diff --git a/java/src/com/android/intentresolver/dagger/InjectedAppComponentFactory.kt b/java/src/com/android/intentresolver/dagger/InjectedAppComponentFactory.kt new file mode 100644 index 00000000..db209ef0 --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/InjectedAppComponentFactory.kt @@ -0,0 +1,92 @@ +/* + * 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.dagger + +import android.app.Activity +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Intent +import android.util.Log +import androidx.core.app.AppComponentFactory +import com.android.intentresolver.ApplicationComponentOwner +import javax.inject.Inject +import javax.inject.Provider + +/** Provides instances of application components, delegates construction to Dagger. */ +class InjectedAppComponentFactory : AppComponentFactory() { + + @set:Inject lateinit var activityComponentBuilder: ActivityComponent.Factory + + @set:Inject + lateinit var receivers: Map<Class<*>, @JvmSuppressWildcards Provider<BroadcastReceiver>> + + override fun instantiateApplicationCompat(cl: ClassLoader, className: String): Application { + val app = super.instantiateApplicationCompat(cl, className) + if (app !is ApplicationComponentOwner) { + throw RuntimeException("App must be ApplicationComponentOwner") + } + app.doWhenApplicationComponentReady { it.inject(this) } + return app + } + + override fun instantiateActivityCompat( + cl: ClassLoader, + className: String, + intent: Intent?, + ): Activity { + return runCatching { + val activities = activityComponentBuilder.create().activities() + instantiate(className, activities) + } + .onFailure { + if (it is UninitializedPropertyAccessException) { + // This should never happen but if it did it would cause errors that could + // be very difficult to identify, so we log it out of an abundance of + // caution. + Log.e(TAG, "Tried to instantiate $className before AppComponent", it) + } + } + .getOrNull() + ?: super.instantiateActivityCompat(cl, className, intent) + } + + override fun instantiateReceiverCompat( + cl: ClassLoader, + className: String, + intent: Intent?, + ): BroadcastReceiver { + return instantiate(className, receivers) + ?: super.instantiateReceiverCompat(cl, className, intent) + } + + private fun <T> instantiate(className: String, providers: Map<Class<*>, Provider<T>>): T? { + return runCatching { providers[Class.forName(className)]?.get() } + .onFailure { + if (it is UninitializedPropertyAccessException) { + // This should never happen but if it did it would cause errors that could + // be very difficult to identify, so we log it out of an abundance of + // caution. + Log.e(TAG, "Tried to instantiate $className before AppComponent", it) + } + } + .getOrNull() + } + + companion object { + private const val TAG = "AppComponentFactory" + } +} diff --git a/java/src/com/android/intentresolver/dagger/InjectedViewModelFactory.kt b/java/src/com/android/intentresolver/dagger/InjectedViewModelFactory.kt new file mode 100644 index 00000000..f0906d3e --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/InjectedViewModelFactory.kt @@ -0,0 +1,84 @@ +/* + * 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.dagger + +import android.net.Uri +import android.os.Bundle +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import java.io.Closeable +import javax.inject.Provider +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive + +/** Instantiates new ViewModel instances using Dagger. */ +class InjectedViewModelFactory( + private val viewModelComponentBuilder: ViewModelComponent.Builder, + creationExtras: CreationExtras, + private val referrer: Uri, +) : ViewModelProvider.Factory { + + private val defaultArgs = creationExtras[DEFAULT_ARGS_KEY] ?: Bundle() + + private fun viewModelScope(viewModelClass: Class<*>) = + CloseableCoroutineScope( + SupervisorJob() + CoroutineName(viewModelClass.simpleName) + Dispatchers.Main.immediate + ) + + private fun <T> newViewModel( + providerMap: Map<Class<*>, Provider<ViewModel>>, + modelClass: Class<T> + ): T { + val provider = + providerMap[modelClass] + ?: error( + "Unable to create an instance of $modelClass. " + + "Does the class have a binding in ViewModelComponent?" + ) + return modelClass.cast(provider.get()) + } + + override fun <T : ViewModel> create(modelClass: Class<T>): T { + val viewModelScope = viewModelScope(modelClass) + val viewModelComponent = + viewModelComponentBuilder + .coroutineScope(viewModelScope) + .intentExtras(defaultArgs) + .referrer(referrer) + .build() + val viewModel = newViewModel(viewModelComponent.viewModels(), modelClass) + viewModel.addCloseable(viewModelScope) + return viewModel + } +} + +internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope { + override val coroutineContext: CoroutineContext = context + + override fun close() { + if (isActive) { + coroutineContext.cancel() + } + } +} diff --git a/java/src/com/android/intentresolver/dagger/ReceiverBinderModule.kt b/java/src/com/android/intentresolver/dagger/ReceiverBinderModule.kt new file mode 100644 index 00000000..32ce2f45 --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/ReceiverBinderModule.kt @@ -0,0 +1,12 @@ +package com.android.intentresolver.dagger + +import android.content.BroadcastReceiver +import dagger.Module +import dagger.multibindings.Multibinds + +/** Injection instructions for injectable [BroadcastReceivers][BroadcastReceiver] */ +@Module +interface ReceiverBinderModule { + + @Multibinds fun bindReceivers(): Map<Class<*>, @JvmSuppressWildcards BroadcastReceiver> +} diff --git a/java/src/com/android/intentresolver/dagger/ViewModelBinderModule.kt b/java/src/com/android/intentresolver/dagger/ViewModelBinderModule.kt new file mode 100644 index 00000000..91ba039c --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/ViewModelBinderModule.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.dagger + +import androidx.lifecycle.ViewModel +import com.android.intentresolver.ui.ChooserViewModel +import dagger.Binds +import dagger.Module +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +/** Defines a map of injectable ViewModel classes. */ +@Module +interface ViewModelBinderModule { + @Binds + @IntoMap + @ClassKey(ChooserViewModel::class) + @ViewModelScope + fun chooserViewModel(viewModel: ChooserViewModel): ViewModel +} diff --git a/java/src/com/android/intentresolver/dagger/ViewModelComponent.kt b/java/src/com/android/intentresolver/dagger/ViewModelComponent.kt new file mode 100644 index 00000000..3e2e2681 --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/ViewModelComponent.kt @@ -0,0 +1,57 @@ +/* + * 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.dagger + +import android.net.Uri +import android.os.Bundle +import com.android.intentresolver.dagger.qualifiers.Referrer +import com.android.intentresolver.dagger.qualifiers.ViewModel +import dagger.BindsInstance +import dagger.Subcomponent +import javax.inject.Provider +import javax.inject.Scope +import kotlin.annotation.AnnotationRetention.RUNTIME +import kotlinx.coroutines.CoroutineScope + +@Scope @Retention(RUNTIME) @MustBeDocumented annotation class ViewModelScope + +/** + * Provides dependencies within [ViewModelScope] within a [ViewModel]. + * + * @see InjectedViewModelFactory + */ +@ViewModelScope +@Subcomponent(modules = [ViewModelModule::class, ViewModelBinderModule::class]) +interface ViewModelComponent { + + /** + * Binds instance values from the creating Activity to make them available for injection within + * [ViewModelScope]. + */ + @Subcomponent.Builder + interface Builder { + @BindsInstance fun intentExtras(@ViewModel intentExtras: Bundle): Builder + + @BindsInstance fun referrer(@Referrer uri: Uri): Builder + + @BindsInstance fun coroutineScope(@ViewModel scope: CoroutineScope): Builder + + fun build(): ViewModelComponent + } + + fun viewModels(): Map<Class<*>, @JvmSuppressWildcards Provider<androidx.lifecycle.ViewModel>> +} diff --git a/java/src/com/android/intentresolver/dagger/ViewModelModule.kt b/java/src/com/android/intentresolver/dagger/ViewModelModule.kt new file mode 100644 index 00000000..23320311 --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/ViewModelModule.kt @@ -0,0 +1,6 @@ +package com.android.intentresolver.dagger + +import dagger.Module + +/** Provides bindings shared among components within [@ViewModelScope][ViewModelScope]. */ +@Module abstract class ViewModelModule diff --git a/java/src/com/android/intentresolver/dagger/qualifiers/Qualifiers.kt b/java/src/com/android/intentresolver/dagger/qualifiers/Qualifiers.kt new file mode 100644 index 00000000..fa50170e --- /dev/null +++ b/java/src/com/android/intentresolver/dagger/qualifiers/Qualifiers.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.dagger.qualifiers + +import javax.inject.Qualifier + +// Note: 'qualifiers' package avoids name collisions in Dagger code. + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class App + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Activity + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ViewModel + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Main + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Delegate + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Default + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Referrer diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt index b303dd1a..2c20d341 100644 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -23,9 +23,8 @@ import com.android.systemui.flags.UnreleasedFlag // make the flags available in the flag flipper app (see go/sysui-flags). // All flags added should be included in UnbundledChooserActivityTest.ALL_FLAGS. object Flags { - private fun releasedFlag(id: Int, name: String) = - ReleasedFlag(id, name, "systemui") + private fun releasedFlag(name: String) = ReleasedFlag(name, "systemui") - private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = - UnreleasedFlag(id, name, "systemui", teamfood) + private fun unreleasedFlag(name: String, teamfood: Boolean = false) = + UnreleasedFlag(name, "systemui", teamfood) } diff --git a/java/src/com/android/intentresolver/icons/LoadIconTask.java b/java/src/com/android/intentresolver/icons/LoadIconTask.java index 37ce4093..75132208 100644 --- a/java/src/com/android/intentresolver/icons/LoadIconTask.java +++ b/java/src/com/android/intentresolver/icons/LoadIconTask.java @@ -24,7 +24,6 @@ import android.os.Trace; import android.os.UserHandle; import android.util.Log; -import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.TargetPresentationGetter; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -64,8 +63,7 @@ class LoadIconTask extends BaseLoadIconTask { protected final Drawable loadIconForResolveInfo(ResolveInfo ri) { // Load icons based on userHandle from ResolveInfo. If in work profile/clone profile, icons // should be badged. - return mPresentationFactory.makePresentationGetter(ri) - .getIcon(ResolverActivity.getResolveInfoUserHandle(ri, mUserHandle)); + return mPresentationFactory.makePresentationGetter(ri).getIcon(ri.userHandle); } } diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/logging/EventLog.java index 1f606f26..b30e825b 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/logging/EventLog.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 The Android Open Source Project + * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.logging; import android.annotation.Nullable; import android.content.Intent; @@ -24,6 +24,7 @@ import android.provider.MediaStore; import android.util.HashedStringCache; import android.util.Log; +import com.android.intentresolver.ChooserActivity; import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.InstanceId; @@ -39,7 +40,7 @@ import com.android.internal.util.FrameworkStatsLog; * Helper for writing Sharesheet atoms to statsd log. * @hide */ -public class ChooserActivityLogger { +public class EventLog { private static final String TAG = "ChooserActivity"; private static final boolean DEBUG = true; @@ -94,12 +95,12 @@ public class ChooserActivityLogger { private final FrameworkStatsLogger mFrameworkStatsLogger; private final MetricsLogger mMetricsLogger; - public ChooserActivityLogger() { + public EventLog() { this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger()); } @VisibleForTesting - ChooserActivityLogger( + EventLog( UiEventLogger uiEventLogger, FrameworkStatsLogger frameworkLogger, MetricsLogger metricsLogger) { diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index bc54e01e..ff2d6a0f 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -30,7 +30,7 @@ import android.os.Message; import android.os.UserHandle; import android.util.Log; -import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.chooser.TargetInfo; @@ -72,7 +72,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC private static final int WATCHDOG_TIMEOUT_MILLIS = 500; private final Comparator<ResolveInfo> mAzComparator; - private ChooserActivityLogger mChooserActivityLogger; + private EventLog mEventLog; protected final Handler mHandler = new Handler(Looper.getMainLooper()) { public void handleMessage(Message msg) { @@ -94,8 +94,8 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC } mHandler.removeMessages(RANKER_SERVICE_RESULT); afterCompute(); - if (mChooserActivityLogger != null) { - mChooserActivityLogger.logSharesheetAppShareRankingTimeout(); + if (mEventLog != null) { + mEventLog.logSharesheetAppShareRankingTimeout(); } break; @@ -161,12 +161,12 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC mAfterCompute = afterCompute; } - void setChooserActivityLogger(ChooserActivityLogger chooserActivityLogger) { - mChooserActivityLogger = chooserActivityLogger; + void setEventLog(EventLog eventLog) { + mEventLog = eventLog; } - ChooserActivityLogger getChooserActivityLogger() { - return mChooserActivityLogger; + EventLog getEventLog() { + return mEventLog; } protected final void afterCompute() { diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index ba054731..621ae306 100644 --- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -31,7 +31,7 @@ import android.os.Message; import android.os.UserHandle; import android.util.Log; -import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -72,7 +72,7 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp String referrerPackage, AppPredictor appPredictor, UserHandle user, - ChooserActivityLogger chooserActivityLogger, + EventLog eventLog, @Nullable ComponentName promoteToFirst) { super(context, intent, Lists.newArrayList(user), promoteToFirst); mContext = context; @@ -80,7 +80,7 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp mAppPredictor = appPredictor; mUser = user; mReferrerPackage = referrerPackage; - setChooserActivityLogger(chooserActivityLogger); + setEventLog(eventLog); mComparatorModel = buildUpdatedModel(); } @@ -116,7 +116,7 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp mIntent, mReferrerPackage, () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), - getChooserActivityLogger(), + getEventLog(), mUser, mPromoteToFirst); mComparatorModel = buildUpdatedModel(); diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index ebaffc36..7d473660 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -39,7 +39,7 @@ import android.service.resolver.ResolverRankerService; import android.service.resolver.ResolverTarget; import android.util.Log; -import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.logging.MetricsLogger; @@ -102,9 +102,9 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace, + EventLog eventLog, UserHandle targetUserSpace, ComponentName promoteToFirst) { - this(launchedFromContext, intent, referrerPackage, afterCompute, chooserActivityLogger, + this(launchedFromContext, intent, referrerPackage, afterCompute, eventLog, Lists.newArrayList(targetUserSpace), promoteToFirst); } @@ -118,7 +118,7 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, List<UserHandle> targetUserSpaceList, + EventLog eventLog, List<UserHandle> targetUserSpaceList, @Nullable ComponentName promoteToFirst) { super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst); mCollator = Collator.getInstance( @@ -139,7 +139,7 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom mAction = intent.getAction(); mRankerServiceName = new ComponentName(mContext, this.getClass()); setCallBack(afterCompute); - setChooserActivityLogger(chooserActivityLogger); + setEventLog(eventLog); mComparatorModel = buildUpdatedModel(); } diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index 3ffbe039..f05542e2 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -136,7 +136,8 @@ constructor( } /** Clear application targets (see [updateAppTargets] and initiate shrtcuts loading. */ - fun reset() { + @OpenForTesting + open fun reset() { Log.d(TAG, "reset shortcut loader for user $userHandle") appTargetSource.tryEmit(null) shortcutSource.tryEmit(null) diff --git a/java/src/com/android/intentresolver/ui/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/ChooserViewModel.kt new file mode 100644 index 00000000..817f0b6c --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ChooserViewModel.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui + +import android.util.Log +import androidx.lifecycle.ViewModel +import com.android.intentresolver.dagger.qualifiers.ViewModel as ViewModelQualifier +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch + +const val TAG = "ChooserViewModel" + +/** The primary container for ViewModelScope dependencies. */ +class ChooserViewModel +@Inject +constructor( + @ViewModelQualifier val viewModelScope: CoroutineScope, +) : ViewModel()
\ No newline at end of file diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 583a2887..3bbafc40 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -39,14 +39,12 @@ import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatu import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import kotlinx.coroutines.plus private const val TRANSITION_NAME = "screenshot_preview_image" private const val PLURALS_COUNT = "count" @@ -127,7 +125,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { isMeasured = true updateMaxWidthHint(widthSpec) updateMaxAspectRatio() - batchLoader?.loadAspectRatios(getMaxWidth(), this::updatePreviewSize) + maybeLoadAspectRatios() } } @@ -145,6 +143,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { ) } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + batchLoader?.totalItemCount?.let(previewAdapter::reset) + maybeLoadAspectRatios() + } + + override fun onDetachedFromWindow() { + batchLoader?.cancel() + super.onDetachedFromWindow() + } + override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) { previewAdapter.transitionStatusElementCallback = callback } @@ -158,32 +167,38 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { return null } - fun setPreviews(previews: List<Preview>, otherItemCount: Int, imageLoader: CachingImageLoader) { - previewAdapter.reset(0, imageLoader) + fun setImageLoader(imageLoader: CachingImageLoader) { + previewAdapter.imageLoader = imageLoader + } + + fun setLoading(totalItemCount: Int) { + previewAdapter.reset(totalItemCount) + } + + fun setPreviews(previews: Flow<Preview>, totalItemCount: Int) { + previewAdapter.reset(totalItemCount) batchLoader?.cancel() batchLoader = BatchPreviewLoader( - imageLoader, - previews, - otherItemCount, - onReset = { totalItemCount -> - previewAdapter.reset(totalItemCount, imageLoader) - }, - onUpdate = previewAdapter::addPreviews, - onCompletion = { - if (!previewAdapter.hasPreviews) { - onNoPreviewCallback?.run() - } - } - ) - .apply { - if (isMeasured) { - loadAspectRatios( - getMaxWidth(), - this@ScrollableImagePreviewView::updatePreviewSize - ) + previewAdapter.imageLoader ?: error("Image loader is not set"), + previews, + totalItemCount, + onUpdate = previewAdapter::addPreviews, + onCompletion = { + batchLoader = null + if (!previewAdapter.hasPreviews) { + onNoPreviewCallback?.run() } + previewAdapter.markLoaded() } + ) + maybeLoadAspectRatios() + } + + private fun maybeLoadAspectRatios() { + if (isMeasured && isAttachedToWindow()) { + batchLoader?.let { it.loadAspectRatios(getMaxWidth(), this::updatePreviewSize) } + } } var onNoPreviewCallback: Runnable? = null @@ -262,10 +277,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { context.resources.getString(R.string.video_preview_a11y_description) private val filePreviewDescription = context.resources.getString(R.string.file_preview_a11y_description) - private var imageLoader: CachingImageLoader? = null + var imageLoader: CachingImageLoader? = null private var firstImagePos = -1 private var totalItemCount: Int = 0 + private var isLoading = false private val hasOtherItem get() = previews.size < totalItemCount val hasPreviews: Boolean @@ -273,61 +289,79 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { var transitionStatusElementCallback: TransitionElementStatusCallback? = null - fun reset(totalItemCount: Int, imageLoader: CachingImageLoader) { - this.imageLoader = imageLoader + fun reset(totalItemCount: Int) { firstImagePos = -1 previews.clear() this.totalItemCount = maxOf(0, totalItemCount) + isLoading = this.totalItemCount > 0 notifyDataSetChanged() } + fun markLoaded() { + if (!isLoading) return + isLoading = false + if (hasOtherItem) { + notifyItemChanged(previews.size) + } else { + notifyItemRemoved(previews.size) + } + } + fun addPreviews(newPreviews: Collection<Preview>) { if (newPreviews.isEmpty()) return val insertPos = previews.size val hadOtherItem = hasOtherItem + val wasEmpty = previews.isEmpty() previews.addAll(newPreviews) if (firstImagePos < 0) { val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image } if (pos >= 0) firstImagePos = insertPos + pos } - notifyItemRangeInserted(insertPos, newPreviews.size) - when { - hadOtherItem && previews.size >= totalItemCount -> { - notifyItemRemoved(previews.size) - } - !hadOtherItem && previews.size < totalItemCount -> { - notifyItemInserted(previews.size) + if (wasEmpty) { + // we don't want any item animation in that case + notifyDataSetChanged() + } else { + notifyItemRangeInserted(insertPos, newPreviews.size) + when { + hadOtherItem && !hasOtherItem -> { + notifyItemRemoved(previews.size) + } + !hadOtherItem && hasOtherItem -> { + notifyItemInserted(previews.size) + } + else -> notifyItemChanged(previews.size) } } } override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder { val view = LayoutInflater.from(context).inflate(itemType, parent, false) - return if (itemType == R.layout.image_preview_other_item) { - OtherItemViewHolder(view) - } else { - PreviewViewHolder( - view, - imagePreviewDescription, - videoPreviewDescription, - filePreviewDescription, - ) + return when (itemType) { + R.layout.image_preview_other_item -> OtherItemViewHolder(view) + R.layout.image_preview_loading_item -> LoadingItemViewHolder(view) + else -> + PreviewViewHolder( + view, + imagePreviewDescription, + videoPreviewDescription, + filePreviewDescription, + ) } } - override fun getItemCount(): Int = previews.size + if (hasOtherItem) 1 else 0 + override fun getItemCount(): Int = previews.size + if (isLoading || hasOtherItem) 1 else 0 - override fun getItemViewType(position: Int): Int { - return if (position == previews.size) { - R.layout.image_preview_other_item - } else { - R.layout.image_preview_image_item + override fun getItemViewType(position: Int): Int = + when { + position == previews.size && isLoading -> R.layout.image_preview_loading_item + position == previews.size -> R.layout.image_preview_other_item + else -> R.layout.image_preview_image_item } - } override fun onBindViewHolder(vh: ViewHolder, position: Int) { when (vh) { is OtherItemViewHolder -> vh.bind(totalItemCount - previews.size) + is LoadingItemViewHolder -> vh.bind() is PreviewViewHolder -> vh.bind( previews[position], @@ -440,7 +474,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } private fun resetScope(): CoroutineScope = - (MainScope() + Dispatchers.Main.immediate).also { + CoroutineScope(Dispatchers.Main.immediate).also { scope?.cancel() scope = it } @@ -466,6 +500,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { override fun unbind() = Unit } + private class LoadingItemViewHolder(view: View) : ViewHolder(view) { + fun bind() = Unit + override fun unbind() = Unit + } + private class SpacingDecoration(private val innerSpacing: Int, private val outerSpacing: Int) : ItemDecoration() { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) { @@ -485,27 +524,22 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { @VisibleForTesting class BatchPreviewLoader( private val imageLoader: CachingImageLoader, - previews: List<Preview>, - otherItemCount: Int, - private val onReset: (Int) -> Unit, + private val previews: Flow<Preview>, + val totalItemCount: Int, private val onUpdate: (List<Preview>) -> Unit, private val onCompletion: () -> Unit, ) { - private val previews: List<Preview> = - if (previews is RandomAccess) previews else ArrayList(previews) - private val totalItemCount = previews.size + otherItemCount - private var scope: CoroutineScope? = MainScope() + Dispatchers.Main.immediate + private var scope: CoroutineScope = createScope() + + private fun createScope() = CoroutineScope(Dispatchers.Main.immediate) fun cancel() { - scope?.cancel() - scope = null + scope.cancel() + scope = createScope() } fun loadAspectRatios(maxWidth: Int, previewSizeUpdater: (Preview, Int, Int) -> Int) { - val scope = this.scope ?: return - // -1 encodes that the preview has not been processed, - // 0 means failed, > 0 is a preview width - val previewWidths = IntArray(previews.size) { -1 } + val previewInfos = ArrayList<PreviewWidthInfo>(totalItemCount) var blockStart = 0 // inclusive var blockEnd = 0 // exclusive @@ -514,26 +548,16 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { val updateEvent = Any() val completedEvent = Any() - // throttle adapter updates using flow; the flow first emits when enough preview - // elements is loaded to fill the viewport and then each time a subsequent block of - // previews is loaded + // collects updates from [reportFlow] throttling adapter updates; scope.launch(Dispatchers.Main) { reportFlow .takeWhile { it !== completedEvent } .throttle(ADAPTER_UPDATE_INTERVAL_MS) - .onCompletion { cause -> - if (cause == null) { - onCompletion() - } - } .collect { - if (blockStart == 0) { - onReset(totalItemCount) - } val updates = ArrayList<Preview>(blockEnd - blockStart) while (blockStart < blockEnd) { - if (previewWidths[blockStart] > 0) { - updates.add(previews[blockStart]) + if (previewInfos[blockStart].width > 0) { + updates.add(previewInfos[blockStart].preview) } blockStart++ } @@ -541,57 +565,64 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { onUpdate(updates) } } + onCompletion() } + // Collects [previews] flow and loads aspect ratios, emits updates into [reportFlow] + // when a next sequential block of preview aspect ratios is loaded: initially emits when + // enough preview elements is loaded to fill the viewport. scope.launch { var blockWidth = 0 var isFirstBlock = true - var nextIdx = 0 - List<Job>(4) { - launch { - while (true) { - val i = nextIdx++ - if (i >= previews.size) break - val preview = previews[i] - - previewWidths[i] = - runCatching { - // TODO: decide on adding a timeout - imageLoader(preview.uri, isFirstBlock)?.let { bitmap -> - previewSizeUpdater( - preview, - bitmap.width, - bitmap.height - ) - } - ?: 0 - } - .getOrDefault(0) - - if (blockEnd != i) continue - while ( - blockEnd < previewWidths.size && previewWidths[blockEnd] >= 0 - ) { - blockWidth += previewWidths[blockEnd] - blockEnd++ - } - if (isFirstBlock) { - if (blockWidth >= maxWidth) { - isFirstBlock = false - // notify that the preview now can be displayed - reportFlow.emit(updateEvent) + + val jobs = ArrayList<Job>() + previews.collect { preview -> + val i = previewInfos.size + val pair = PreviewWidthInfo(preview) + previewInfos.add(pair) + + val job = launch { + pair.width = + runCatching { + // TODO: decide on adding a timeout. The worst case I can + // 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 -> + previewSizeUpdater(preview, bitmap.width, bitmap.height) } - } else { - reportFlow.emit(updateEvent) + ?: 0 } + .getOrDefault(0) + + if (i == blockEnd) { + while ( + blockEnd < previewInfos.size && previewInfos[blockEnd].width >= 0 + ) { + blockWidth += previewInfos[blockEnd].width + blockEnd++ + } + if (isFirstBlock && blockWidth >= maxWidth) { + isFirstBlock = false + } + if (!isFirstBlock) { + reportFlow.emit(updateEvent) } } } - .joinAll() + jobs.add(job) + } + jobs.joinAll() // in case all previews have failed to load reportFlow.emit(updateEvent) reportFlow.emit(completedEvent) } } } + + private class PreviewWidthInfo(val preview: Preview) { + // -1 encodes that the preview has not been processed, + // 0 means failed, > 0 is a preview width + var width: Int = -1 + } } diff --git a/java/tests/Android.bp b/java/tests/Android.bp index c381d0a8..bb287eb2 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -19,18 +19,21 @@ android_test { static_libs: [ "IntentResolver-core", - "androidx.test.rules", + "androidx.test.core", "androidx.test.ext.junit", + "androidx.test.ext.truth", "androidx.test.espresso.contrib", - "mockito-target-minus-junit4", "androidx.test.espresso.core", + "androidx.test.rules", "androidx.lifecycle_lifecycle-common-java8", "androidx.lifecycle_lifecycle-extensions", "androidx.lifecycle_lifecycle-runtime-ktx", - "truth-prebuilt", - "testables", "kotlinx_coroutines_test", + "mockito-target-minus-junit4", + "testables", + "truth-prebuilt", ], + plugins: ["dagger2-compiler"], test_suites: ["general-tests"], sdk_version: "core_platform", compile_multilib: "both", diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index 05830c4c..9f8dd41c 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -15,7 +15,8 @@ --> <manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.android.intentresolver.tests"> + xmlns:tools="http://schemas.android.com/tools" + package="com.android.intentresolver.tests"> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30" /> @@ -25,7 +26,10 @@ <uses-permission android:name="android.permission.WRITE_DEVICE_CONFIG"/> <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" /> - <application android:name="com.android.intentresolver.TestApplication"> + <application + android:name="com.android.intentresolver.TestApplication" + tools:replace="android:appComponentFactory" + android:appComponentFactory="com.android.intentresolver.dagger.InjectedAppComponentFactory"> <uses-library android:name="android.test.runner" /> <activity android:name="com.android.intentresolver.ChooserWrapperActivity" /> <activity android:name="com.android.intentresolver.ResolverWrapperActivity" /> diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt index 8d994f08..af6e5f16 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -27,6 +27,7 @@ import android.graphics.drawable.Icon import android.service.chooser.ChooserAction import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.logging.EventLog import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch @@ -43,7 +44,7 @@ import org.mockito.Mockito class ChooserActionFactoryTest { private val context = InstrumentationRegistry.getInstrumentation().getContext() - private val logger = mock<ChooserActivityLogger>() + private val logger = mock<EventLog>() private val actionLabel = "Action label" private val modifyShareLabel = "Modify share" private val testAction = "com.android.intentresolver.testaction" @@ -107,7 +108,7 @@ class ChooserActionFactoryTest { action.onClicked.run() Mockito.verify(logger) - .logActionSelected(eq(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE)) + .logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE)) assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) // Verify the pending intent has been called countdown.await(500, TimeUnit.MILLISECONDS) diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index ce96ef63..84f5124c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -30,6 +30,7 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileI import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; import java.util.function.Consumer; @@ -64,7 +65,7 @@ public class ChooserActivityOverrideData { public Cursor resolverCursor; public boolean resolverForceException; public ImageLoader imageLoader; - public ChooserActivityLogger chooserActivityLogger; + public EventLog mEventLog; public int alternateProfileSetting; public Resources resources; public UserHandle workProfileUserHandle; @@ -87,7 +88,7 @@ public class ChooserActivityOverrideData { resolverForceException = false; resolverListController = mock(ChooserActivity.ChooserListController.class); workResolverListController = mock(ChooserActivity.ChooserListController.class); - chooserActivityLogger = mock(ChooserActivityLogger.class); + mEventLog = mock(EventLog.class); alternateProfileSetting = 0; resources = null; workProfileUserHandle = null; diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt index 4612b430..c8cb4b9b 100644 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -31,6 +31,7 @@ import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo import com.android.intentresolver.chooser.TargetInfo import com.android.intentresolver.icons.TargetDataLoader +import com.android.intentresolver.logging.EventLog import com.android.internal.R import org.junit.Before import org.junit.Test @@ -49,7 +50,7 @@ class ChooserListAdapterTest { } private val context = InstrumentationRegistry.getInstrumentation().context private val resolverListController = mock<ResolverListController>() - private val chooserActivityLogger = mock<ChooserActivityLogger>() + private val mEventLog = mock<EventLog>() private val mTargetDataLoader = mock<TargetDataLoader>() private val testSubject by lazy { @@ -64,7 +65,7 @@ class ChooserListAdapterTest { Intent(), mock(), packageManager, - chooserActivityLogger, + mEventLog, mock(), 0, null, diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 6ac6b6d3..49305a6c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -37,15 +37,19 @@ import androidx.lifecycle.ViewModelProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.dagger.TestViewModelComponent; import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import java.util.List; import java.util.function.Consumer; +import javax.inject.Inject; + /** * Simple wrapper around chooser activity to be able to initiate it under test. For more * information, see {@code com.android.internal.app.ChooserWrapperActivity}. @@ -55,6 +59,11 @@ public class ChooserWrapperActivity static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); private UsageStatsManager mUsm; + @Inject + public ChooserWrapperActivity(TestViewModelComponent.Builder builder) { + super(builder); + } + // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at // onCreate and needs to see some non-negative value in the test. @Override @@ -89,7 +98,7 @@ public class ChooserWrapperActivity targetIntent, this, packageManager, - getChooserActivityLogger(), + getEventLog(), chooserRequest, maxTargetsPerRow, userHandle, @@ -205,8 +214,8 @@ public class ChooserWrapperActivity } @Override - public ChooserActivityLogger getChooserActivityLogger() { - return sOverrides.chooserActivityLogger; + public EventLog getEventLog() { + return sOverrides.mEventLog; } @Override diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java index af897a47..3326d7f2 100644 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -23,6 +23,7 @@ import android.content.pm.ResolveInfo; import android.os.UserHandle; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.logging.EventLog; import java.util.concurrent.Executor; @@ -41,6 +42,6 @@ public interface IChooserWrapper { CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, @Nullable TargetPresentationGetter resolveInfoPresentationGetter); UserHandle getCurrentUserHandle(); - ChooserActivityLogger getChooserActivityLogger(); + EventLog getEventLog(); Executor getMainExecutor(); } diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index 688dd867..1f8d9bee 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -103,30 +103,26 @@ public class ResolverDataProvider { } public static ResolveInfo createResolveInfo(int i, int userId) { - final ResolveInfo resolveInfo = new ResolveInfo(); - resolveInfo.activityInfo = createActivityInfo(i); - resolveInfo.targetUserId = userId; - return resolveInfo; + return createResolveInfo(i, userId, UserHandle.of(userId)); } + public static ResolveInfo createResolveInfo(int i, int userId, UserHandle resolvedForUser) { - final ResolveInfo resolveInfo = new ResolveInfo(); - resolveInfo.activityInfo = createActivityInfo(i); - resolveInfo.targetUserId = userId; - resolveInfo.userHandle = resolvedForUser; - return resolveInfo; + return createResolveInfo(createActivityInfo(i), userId, resolvedForUser); } public static ResolveInfo createResolveInfo(ComponentName componentName, int userId) { - final ResolveInfo resolveInfo = new ResolveInfo(); - resolveInfo.activityInfo = createActivityInfo(componentName); - resolveInfo.targetUserId = userId; - return resolveInfo; + return createResolveInfo(componentName, userId, UserHandle.of(userId)); } - public static ResolveInfo createResolveInfo(ComponentName componentName, int userId, - UserHandle resolvedForUser) { + public static ResolveInfo createResolveInfo( + ComponentName componentName, int userId, UserHandle resolvedForUser) { + return createResolveInfo(createActivityInfo(componentName), userId, resolvedForUser); + } + + public static ResolveInfo createResolveInfo( + ActivityInfo activityInfo, int userId, UserHandle resolvedForUser) { final ResolveInfo resolveInfo = new ResolveInfo(); - resolveInfo.activityInfo = createActivityInfo(componentName); + resolveInfo.activityInfo = activityInfo; resolveInfo.targetUserId = userId; resolveInfo.userHandle = resolvedForUser; return resolveInfo; diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index 401ede26..11e7dffd 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -44,6 +44,8 @@ import java.util.List; import java.util.function.Consumer; import java.util.function.Function; +import javax.inject.Inject; + /* * Simple wrapper around chooser activity to be able to initiate it under test */ @@ -53,6 +55,7 @@ public class ResolverWrapperActivity extends ResolverActivity { private final CountingIdlingResource mLabelIdlingResource = new CountingIdlingResource("LoadLabelTask"); + @Inject public ResolverWrapperActivity() { super(/* isIntentPicker= */ true); } diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/java/tests/src/com/android/intentresolver/TestApplication.kt index 849cfbab..4f5aefb9 100644 --- a/java/tests/src/com/android/intentresolver/TestApplication.kt +++ b/java/tests/src/com/android/intentresolver/TestApplication.kt @@ -16,12 +16,13 @@ package com.android.intentresolver -import android.app.Application import android.content.Context import android.os.UserHandle +import com.android.intentresolver.dagger.DaggerTestApplicationComponent -class TestApplication : Application() { +class TestApplication : IntentResolverApplication() { + override fun createApplicationComponentBuilder() = DaggerTestApplicationComponent.builder() // return the current context as a work profile doesn't really exist in these tests override fun createContextAsUser(user: UserHandle, flags: Int): Context = this -}
\ No newline at end of file +} diff --git a/java/tests/src/com/android/intentresolver/TestContentProvider.kt b/java/tests/src/com/android/intentresolver/TestContentProvider.kt index b3b53baa..426f9af2 100644 --- a/java/tests/src/com/android/intentresolver/TestContentProvider.kt +++ b/java/tests/src/com/android/intentresolver/TestContentProvider.kt @@ -30,15 +30,23 @@ class TestContentProvider : ContentProvider() { sortOrder: String? ): Cursor? = null - override fun getType(uri: Uri): String? - = runCatching { - uri.getQueryParameter("mimeType") - }.getOrNull() + override fun getType(uri: Uri): String? = + runCatching { uri.getQueryParameter(PARAM_MIME_TYPE) }.getOrNull() - override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>? - = runCatching { - uri.getQueryParameter("streamType")?.let { arrayOf(it) } - }.getOrNull() + override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>? { + val delay = + runCatching { uri.getQueryParameter(PARAM_STREAM_TYPE_TIMEOUT)?.toLong() ?: 0L } + .getOrDefault(0L) + if (delay > 0) { + try { + Thread.sleep(delay) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } + } + return runCatching { uri.getQueryParameter(PARAM_STREAM_TYPE)?.let { arrayOf(it) } } + .getOrNull() + } override fun insert(uri: Uri, values: ContentValues?): Uri? = null @@ -52,4 +60,10 @@ class TestContentProvider : ContentProvider() { ): Int = 0 override fun onCreate(): Boolean = true -}
\ No newline at end of file + + companion object { + const val PARAM_MIME_TYPE = "mimeType" + const val PARAM_STREAM_TYPE = "streamType" + const val PARAM_STREAM_TYPE_TIMEOUT = "streamTypeTo" + } +} diff --git a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt index f47e343f..7e588f98 100644 --- a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt +++ b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt @@ -23,7 +23,7 @@ import androidx.lifecycle.LifecycleRegistry internal class TestLifecycleOwner : LifecycleOwner { private val lifecycleRegistry = LifecycleRegistry.createUnsafe(this) - override fun getLifecycle(): Lifecycle = lifecycleRegistry + override val lifecycle: Lifecycle get() = lifecycleRegistry var state: Lifecycle.State get() = lifecycle.currentState diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 3ddd4394..b8b57403 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -52,6 +52,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -107,6 +108,7 @@ import androidx.test.rule.ActivityTestRule; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -134,6 +136,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; @@ -862,7 +868,7 @@ public class UnbundledChooserActivityTest { } @Test - public void copyTextToClipboard() throws Exception { + public void copyTextToClipboard() { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -877,7 +883,8 @@ public class UnbundledChooserActivityTest { ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( Context.CLIPBOARD_SERVICE); ClipData clipData = clipboard.getPrimaryClip(); - assertThat("testing intent sending", is(clipData.getItemAt(0).getText())); + assertThat(clipData).isNotNull(); + assertThat(clipData.getItemAt(0).getText()).isEqualTo("testing intent sending"); ClipDescription clipDescription = clipData.getDescription(); assertThat("text/plain", is(clipDescription.getMimeType(0))); @@ -899,8 +906,8 @@ public class UnbundledChooserActivityTest { onView(withId(R.id.copy)).check(matches(isDisplayed())); onView(withId(R.id.copy)).perform(click()); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); - verify(logger, times(1)).logActionSelected(eq(ChooserActivityLogger.SELECTION_TYPE_COPY)); + EventLog logger = activity.getEventLog(); + verify(logger, times(1)).logActionSelected(eq(EventLog.SELECTION_TYPE_COPY)); } @Test @@ -1003,6 +1010,55 @@ public class UnbundledChooserActivityTest { } @Test + public void testSlowUriMetadata_fallbackToFilePreview() throws InterruptedException { + Uri uri = createTestContentProviderUri( + "application/pdf", "image/png", /*streamTypeTimeout=*/4_000); + ArrayList<Uri> uris = new ArrayList<>(1); + uris.add(uri); + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 2_000)) + .isTrue(); + waitForIdle(); + + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test + public void testSendManyFilesWithSmallMetadataDelayAndOneImage_fallbackToFilePreviewUi() + throws InterruptedException { + Uri fileUri = createTestContentProviderUri( + "application/pdf", "application/pdf", /*streamTypeTimeout=*/150); + Uri imageUri = createTestContentProviderUri("application/pdf", "image/png"); + ArrayList<Uri> uris = new ArrayList<>(50); + for (int i = 0; i < 49; i++) { + uris.add(fileUri); + } + uris.add(imageUri); + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(imageUri, createBitmap()); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 2_000)) + .isTrue(); + + waitForIdle(); + + onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); + onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); + onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); + } + + @Test public void testManyVisibleImagePreview_ScrollableImagePreview() { Uri uri = createTestContentProviderUri("image/png", null); @@ -1039,6 +1095,63 @@ public class UnbundledChooserActivityTest { } @Test + public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart() + throws InterruptedException { + Uri imgOneUri = createTestContentProviderUri("image/png", null); + Uri imgTwoUri = createTestContentProviderUri("image/png", null) + .buildUpon() + .path("image-2.png") + .build(); + Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 3_000); + ArrayList<Uri> uris = new ArrayList<>(2); + // two large previews to fill the screen and be presented right away and one + // document that would be delayed by the URI metadata reading + uris.add(imgOneUri); + uris.add(imgTwoUri); + uris.add(docUri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + Map<Uri, Bitmap> bitmaps = new HashMap<>(); + bitmaps.put(imgOneUri, createWideBitmap(Color.RED)); + bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN)); + bitmaps.put(docUri, createWideBitmap(Color.BLUE)); + ChooserActivityOverrideData.getInstance().imageLoader = + new TestPreviewImageLoader(bitmaps); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 1_000)) + .isTrue(); + waitForIdle(); + + onView(withId(R.id.scrollable_image_preview)) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getChildCount()).isAtLeast(1); + // the first view is a preview + View imageView = recyclerView.getChildAt(0).findViewById(R.id.image); + assertThat(imageView).isNotNull(); + }) + .perform(RecyclerViewActions.scrollToLastPosition()) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getChildCount()).isAtLeast(1); + // check that the last view is a loading indicator + View loadingIndicator = + recyclerView.getChildAt(recyclerView.getChildCount() - 1); + assertThat(loadingIndicator).isNotNull(); + }); + waitForIdle(); + } + + @Test public void testImageAndTextPreview() { final Uri uri = createTestContentProviderUri("image/png", null); final String sharedText = "text-" + System.currentTimeMillis(); @@ -1099,7 +1212,7 @@ public class UnbundledChooserActivityTest { final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); + EventLog logger = activity.getEventLog(); waitForIdle(); verify(logger).logChooserActivityShown(eq(false), eq(TEST_MIME_TYPE), anyLong()); @@ -1114,7 +1227,7 @@ public class UnbundledChooserActivityTest { final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); + EventLog logger = activity.getEventLog(); waitForIdle(); verify(logger).logChooserActivityShown(eq(true), eq(TEST_MIME_TYPE), anyLong()); @@ -1127,7 +1240,7 @@ public class UnbundledChooserActivityTest { final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( Intent.createChooser(sendIntent, "empty preview logger test")); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); + EventLog logger = activity.getEventLog(); waitForIdle(); verify(logger).logChooserActivityShown(eq(false), eq(null), anyLong()); @@ -1146,7 +1259,7 @@ public class UnbundledChooserActivityTest { waitForIdle(); // Second invocation is from onCreate - ChooserActivityLogger logger = activity.getChooserActivityLogger(); + EventLog logger = activity.getEventLog(); Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_TEXT)); } @@ -1168,7 +1281,7 @@ public class UnbundledChooserActivityTest { final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); + EventLog logger = activity.getEventLog(); Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_IMAGE)); } @@ -1370,8 +1483,8 @@ public class UnbundledChooserActivityTest { ArgumentCaptor<HashedStringCache.HashResult> hashCaptor = ArgumentCaptor.forClass(HashedStringCache.HashResult.class); - verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected( - eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), + verify(activity.getEventLog(), times(1)).logShareTargetSelected( + eq(EventLog.SELECTION_TYPE_SERVICE), /* packageName= */ any(), /* positionPicked= */ anyInt(), /* directTargetAlsoRanked= */ eq(-1), @@ -1451,8 +1564,8 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected( - eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), + verify(activity.getEventLog(), times(1)).logShareTargetSelected( + eq(EventLog.SELECTION_TYPE_SERVICE), /* packageName= */ any(), /* positionPicked= */ anyInt(), /* directTargetAlsoRanked= */ eq(0), @@ -1466,14 +1579,10 @@ public class UnbundledChooserActivityTest { @Test public void testShortcutTargetWithApplyAppLimits() { // Set up resources - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( + Resources resources = Mockito.spy( InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getInteger(R.integer.config_maxShortcutTargetsPerApp)) - .thenReturn(1); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1541,14 +1650,10 @@ public class UnbundledChooserActivityTest { SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, Boolean.toString(false)); // Set up resources - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( + Resources resources = Mockito.spy( InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getInteger(R.integer.config_maxShortcutTargetsPerApp)) - .thenReturn(1); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1620,14 +1725,10 @@ public class UnbundledChooserActivityTest { SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, Boolean.toString(false)); // Set up resources - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( + Resources resources = Mockito.spy( InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getInteger(R.integer.config_maxShortcutTargetsPerApp)) - .thenReturn(1); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1823,14 +1924,10 @@ public class UnbundledChooserActivityTest { .getResources().getConfiguration()); configuration.orientation = orientation; - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( + Resources resources = Mockito.spy( InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getConfiguration()) - .thenReturn(configuration); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(configuration).when(resources).getConfiguration(); Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed @@ -1877,9 +1974,9 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - ChooserActivityLogger logger = wrapper.getChooserActivityLogger(); + EventLog logger = wrapper.getEventLog(); verify(logger, times(1)).logShareTargetSelected( - eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), + eq(EventLog.SELECTION_TYPE_SERVICE), /* packageName= */ any(), /* positionPicked= */ anyInt(), // The packages sholdn't match for app target and direct target: @@ -2209,10 +2306,10 @@ public class UnbundledChooserActivityTest { .perform(click()); waitForIdle(); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); + EventLog logger = activity.getEventLog(); ArgumentCaptor<Integer> typeCaptor = ArgumentCaptor.forClass(Integer.class); verify(logger, times(1)).logShareTargetSelected( - eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), + eq(EventLog.SELECTION_TYPE_SERVICE), /* packageName= */ any(), /* positionPicked= */ anyInt(), /* directTargetAlsoRanked= */ anyInt(), @@ -2654,15 +2751,25 @@ public class UnbundledChooserActivityTest { private Uri createTestContentProviderUri( @Nullable String mimeType, @Nullable String streamType) { + return createTestContentProviderUri(mimeType, streamType, 0); + } + + private Uri createTestContentProviderUri( + @Nullable String mimeType, @Nullable String streamType, long streamTypeTimeout) { String packageName = InstrumentationRegistry.getInstrumentation().getContext().getPackageName(); Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png") .buildUpon(); if (mimeType != null) { - builder.appendQueryParameter("mimeType", mimeType); + builder.appendQueryParameter(TestContentProvider.PARAM_MIME_TYPE, mimeType); } if (streamType != null) { - builder.appendQueryParameter("streamType", streamType); + builder.appendQueryParameter(TestContentProvider.PARAM_STREAM_TYPE, streamType); + } + if (streamTypeTimeout > 0) { + builder.appendQueryParameter( + TestContentProvider.PARAM_STREAM_TYPE_TIMEOUT, + Long.toString(streamTypeTimeout)); } return builder.build(); } @@ -2792,11 +2899,44 @@ public class UnbundledChooserActivityTest { InstrumentationRegistry.getInstrumentation().waitForIdleSync(); } + private boolean launchActivityWithTimeout(Intent intent, long timeout) + throws InterruptedException { + final int initialState = 0; + final int completedState = 1; + final int timeoutState = 2; + final AtomicInteger state = new AtomicInteger(initialState); + final CountDownLatch cdl = new CountDownLatch(1); + + ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); + try { + executor.execute(() -> { + mActivityRule.launchActivity(intent); + state.compareAndSet(initialState, completedState); + cdl.countDown(); + }); + executor.schedule( + () -> { + state.compareAndSet(initialState, timeoutState); + cdl.countDown(); + }, + timeout, + TimeUnit.MILLISECONDS); + cdl.await(); + return state.get() == completedState; + } finally { + executor.shutdownNow(); + } + } + private Bitmap createBitmap() { return createBitmap(200, 200); } private Bitmap createWideBitmap() { + return createWideBitmap(Color.RED); + } + + private Bitmap createWideBitmap(int bgColor) { WindowManager windowManager = InstrumentationRegistry.getInstrumentation() .getTargetContext() .getSystemService(WindowManager.class); @@ -2805,15 +2945,19 @@ public class UnbundledChooserActivityTest { Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); width = bounds.width() + 200; } - return createBitmap(width, 100); + return createBitmap(width, 100, bgColor); } private Bitmap createBitmap(int width, int height) { + return createBitmap(width, height, Color.RED); + } + + private Bitmap createBitmap(int width, int height, int bgColor) { Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); Paint paint = new Paint(); - paint.setColor(Color.RED); + paint.setColor(bgColor); paint.setStyle(Paint.Style.FILL); canvas.drawPaint(paint); @@ -2941,14 +3085,11 @@ public class UnbundledChooserActivityTest { } private void updateMaxTargetsPerRowResource(int targetsPerRow) { - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( + Resources resources = Mockito.spy( InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getInteger(R.integer.config_chooser_max_targets_per_row)) - .thenReturn(targetsPerRow); + ChooserActivityOverrideData.getInstance().resources = resources; + doReturn(targetsPerRow).when(resources).getInteger( + R.integer.config_chooser_max_targets_per_row); } private SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 9bfd2052..008cc162 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -20,7 +20,7 @@ import android.content.Intent import android.graphics.Bitmap import android.net.Uri import androidx.lifecycle.Lifecycle -import com.android.intentresolver.any +import com.android.intentresolver.TestLifecycleOwner import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory import com.android.intentresolver.mock import com.android.intentresolver.whenever @@ -28,13 +28,14 @@ import com.android.intentresolver.widget.ActionRow import com.android.intentresolver.widget.ImagePreviewView import com.google.common.truth.Truth.assertThat import java.util.function.Consumer +import kotlinx.coroutines.flow.MutableSharedFlow import org.junit.Test import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify class ChooserContentPreviewUiTest { - private val lifecycle = mock<Lifecycle>() + private val lifecycleOwner = TestLifecycleOwner() private val previewData = mock<PreviewDataProvider>() private val headlineGenerator = mock<HeadlineGenerator>() private val imageLoader = @@ -64,7 +65,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT) val testSubject = ChooserContentPreviewUi( - lifecycle, + lifecycleOwner.lifecycle, previewData, Intent(Intent.ACTION_VIEW), imageLoader, @@ -83,7 +84,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE) val testSubject = ChooserContentPreviewUi( - lifecycle, + lifecycleOwner.lifecycle, previewData, Intent(Intent.ACTION_SEND), imageLoader, @@ -104,9 +105,10 @@ class ChooserContentPreviewUiTest { whenever(previewData.uriCount).thenReturn(2) whenever(previewData.firstFileInfo) .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) + whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = ChooserContentPreviewUi( - lifecycle, + lifecycleOwner.lifecycle, previewData, Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") }, imageLoader, @@ -116,7 +118,7 @@ class ChooserContentPreviewUiTest { ) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) - verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any()) + verify(previewData, times(1)).imagePreviewFileInfoFlow verify(transitionCallback, times(1)).onAllTransitionElementsReady() } @@ -127,9 +129,10 @@ class ChooserContentPreviewUiTest { whenever(previewData.uriCount).thenReturn(2) whenever(previewData.firstFileInfo) .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) + whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = ChooserContentPreviewUi( - lifecycle, + lifecycleOwner.lifecycle, previewData, Intent(Intent.ACTION_SEND), imageLoader, @@ -140,7 +143,7 @@ class ChooserContentPreviewUiTest { assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java) - verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any()) + verify(previewData, times(1)).imagePreviewFileInfoFlow verify(transitionCallback, never()).onAllTransitionElementsReady() } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt new file mode 100644 index 00000000..06ade1ce --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -0,0 +1,225 @@ +/* + * 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.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.android.intentresolver.R +import com.android.intentresolver.TestLifecycleOwner +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.android.intentresolver.widget.ActionRow +import com.google.common.truth.Truth.assertThat +import java.util.function.Consumer +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +private const val HEADLINE_IMAGES = "Image Headline" +private const val HEADLINE_VIDEOS = "Video Headline" +private const val HEADLINE_FILES = "Files Headline" +private const val SHARED_TEXT = "Some text to share" + +@RunWith(AndroidJUnit4::class) +class FilesPlusTextContentPreviewUiTest { + private val lifecycleOwner = TestLifecycleOwner() + private val actionFactory = + object : ChooserContentPreviewUi.ActionFactory { + override fun getEditButtonRunnable(): Runnable? = null + override fun getCopyButtonRunnable(): Runnable? = null + override fun createCustomActions(): List<ActionRow.Action> = emptyList() + override fun getModifyShareAction(): ActionRow.Action? = null + override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {} + } + private val imageLoader = mock<ImageLoader>() + private val headlineGenerator = + mock<HeadlineGenerator> { + whenever(getImagesHeadline(anyInt())).thenReturn(HEADLINE_IMAGES) + whenever(getVideosHeadline(anyInt())).thenReturn(HEADLINE_VIDEOS) + whenever(getFilesHeadline(anyInt())).thenReturn(HEADLINE_FILES) + } + + private val context + get() = getInstrumentation().getContext() + + @Test + fun test_displayImagesPlusTextWithoutUriMetadata_showImagesHeadline() { + val sharedFileCount = 2 + val previewView = testLoadingHeadline("image/*", sharedFileCount) + + verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_IMAGES) + verifySharedText(previewView) + } + + @Test + fun test_displayVideosPlusTextWithoutUriMetadata_showVideosHeadline() { + val sharedFileCount = 2 + val previewView = testLoadingHeadline("video/*", sharedFileCount) + + verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) + verifySharedText(previewView) + } + + @Test + fun test_displayDocsPlusTextWithoutUriMetadata_showFilesHeadline() { + val sharedFileCount = 2 + val previewView = testLoadingHeadline("application/pdf", sharedFileCount) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifySharedText(previewView) + } + + @Test + fun test_displayMixedContentPlusTextWithoutUriMetadata_showFilesHeadline() { + val sharedFileCount = 2 + val previewView = testLoadingHeadline("*/*", sharedFileCount) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifySharedText(previewView) + } + + @Test + fun test_displayImagesPlusTextWithUriMetadataSet_showImagesHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg") + val sharedFileCount = loadedFileMetadata.size + val previewView = testLoadingHeadline("image/*", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_IMAGES) + verifySharedText(previewView) + } + + @Test + fun test_displayVideosPlusTextWithUriMetadataSet_showVideosHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4") + val sharedFileCount = loadedFileMetadata.size + val previewView = testLoadingHeadline("video/*", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) + verifySharedText(previewView) + } + + @Test + fun test_displayImagesAndVideosPlusTextWithUriMetadataSet_showFilesHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4") + val sharedFileCount = loadedFileMetadata.size + val previewView = testLoadingHeadline("*/*", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifySharedText(previewView) + } + + @Test + fun test_displayDocsPlusTextWithUriMetadataSet_showFilesHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf") + val sharedFileCount = loadedFileMetadata.size + val previewView = + testLoadingHeadline("application/pdf", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifySharedText(previewView) + } + + @Test + fun test_uriMetadataIsMoreSpecificThanIntentMimeType_headlineGetsUpdated() { + val sharedFileCount = 2 + val testSubject = + FilesPlusTextContentPreviewUi( + lifecycleOwner.lifecycle, + /*isSingleImage=*/ false, + sharedFileCount, + SHARED_TEXT, + /*intentMimeType=*/ "*/*", + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + headlineGenerator + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + + val previewView = + testSubject.display(context.resources, LayoutInflater.from(context), gridLayout) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_FILES) + + testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg")) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) + verifyPreviewHeadline(previewView, HEADLINE_IMAGES) + } + + private fun testLoadingHeadline( + intentMimeType: String, + sharedFileCount: Int, + loadedFileMetadata: List<FileInfo>? = null + ): ViewGroup? { + val testSubject = + FilesPlusTextContentPreviewUi( + lifecycleOwner.lifecycle, + /*isSingleImage=*/ false, + sharedFileCount, + SHARED_TEXT, + intentMimeType, + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + headlineGenerator + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + + loadedFileMetadata?.let(testSubject::updatePreviewMetadata) + return testSubject.display(context.resources, LayoutInflater.from(context), gridLayout) + } + + private fun createFileInfosWithMimeTypes(vararg mimeTypes: String): List<FileInfo> { + val uri = Uri.parse("content://pkg.app/file") + return mimeTypes.map { mimeType -> FileInfo.Builder(uri).withMimeType(mimeType).build() } + } + + private fun verifyPreviewHeadline(previewView: ViewGroup?, expectedText: String) { + assertThat(previewView).isNotNull() + val headlineView = previewView?.findViewById<TextView>(R.id.headline) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(expectedText) + } + + private fun verifySharedText(previewView: ViewGroup?) { + assertThat(previewView).isNotNull() + val textContentView = previewView?.findViewById<TextView>(R.id.content_preview_text) + assertThat(textContentView).isNotNull() + assertThat(textContentView?.text).isEqualTo(SHARED_TEXT) + } +} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt index 145b89ad..6599baa9 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -22,18 +22,15 @@ import android.database.MatrixCursor import android.media.MediaMetadata import android.net.Uri import android.provider.DocumentsContract -import androidx.lifecycle.Lifecycle -import com.android.intentresolver.TestLifecycleOwner import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before +import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.Mockito.any import org.mockito.Mockito.never @@ -44,27 +41,13 @@ import org.mockito.Mockito.verify class PreviewDataProviderTest { private val contentResolver = mock<ContentInterface>() private val mimeTypeClassifier = DefaultMimeTypeClassifier - - private val lifecycleOwner = TestLifecycleOwner() - private val dispatcher = UnconfinedTestDispatcher() - - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - lifecycleOwner.state = Lifecycle.State.CREATED - } - - @After - fun cleanup() { - lifecycleOwner.state = Lifecycle.State.DESTROYED - Dispatchers.resetMain() - } + private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) @Test fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() { val targetIntent = Intent(Intent.ACTION_VIEW) val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) verify(contentResolver, never()).getType(any()) @@ -73,14 +56,14 @@ class PreviewDataProviderTest { @Test fun test_sendSingleTextFileWithoutPreview_resolvesToFilePreviewUi() { val uri = Uri.parse("content://org.pkg.app/notes.txt") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { + val targetIntent = + Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) type = "text/plain" } whenever(contentResolver.getType(uri)).thenReturn("text/plain") val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -90,12 +73,9 @@ class PreviewDataProviderTest { @Test fun test_sendIntentWithoutUris_resolvesToTextPreviewUiSynchronously() { - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - type = "image/png" - } + val targetIntent = Intent(Intent.ACTION_SEND).apply { type = "image/png" } val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) verify(contentResolver, never()).getType(any()) @@ -104,13 +84,10 @@ class PreviewDataProviderTest { @Test fun test_sendSingleImage_resolvesToImagePreviewUi() { val uri = Uri.parse("content://org.pkg.app/image.png") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - putExtra(Intent.EXTRA_STREAM, uri) - } + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("image/png") val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -122,13 +99,10 @@ class PreviewDataProviderTest { @Test fun test_sendSingleNonImage_resolvesToFilePreviewUi() { val uri = Uri.parse("content://org.pkg.app/paper.pdf") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - putExtra(Intent.EXTRA_STREAM, uri) - } + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("application/pdf") val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -141,14 +115,13 @@ class PreviewDataProviderTest { fun test_sendSingleImageWithFailingGetType_resolvesToFilePreviewUi() { val uri = Uri.parse("content://org.pkg.app/image.png") val targetIntent = - Intent(Intent.ACTION_SEND) - .apply { - type = "image/png" - putExtra(Intent.EXTRA_STREAM, uri) - } + Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + } whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure")) val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -161,17 +134,16 @@ class PreviewDataProviderTest { fun test_sendSingleImageWithFailingMetadata_resolvesToFilePreviewUi() { val uri = Uri.parse("content://org.pkg.app/image.png") val targetIntent = - Intent(Intent.ACTION_SEND) - .apply { - type = "image/png" - putExtra(Intent.EXTRA_STREAM, uri) - } + Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + } whenever(contentResolver.getStreamTypes(uri, "*/*")) .thenThrow(SecurityException("test failure")) whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) .thenThrow(SecurityException("test failure")) val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -183,14 +155,11 @@ class PreviewDataProviderTest { @Test fun test_SingleNonImageUriWithImageTypeInGetStreamTypes_useImagePreviewUi() { val uri = Uri.parse("content://org.pkg.app/paper.pdf") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - putExtra(Intent.EXTRA_STREAM, uri) - } + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getStreamTypes(uri, "*/*")) .thenReturn(arrayOf("application/pdf", "image/png")) val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -221,15 +190,12 @@ class PreviewDataProviderTest { private fun testMetadataToImagePreview(columns: Array<String>, values: Array<Any>) { val uri = Uri.parse("content://org.pkg.app/test.pdf") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - putExtra(Intent.EXTRA_STREAM, uri) - } + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("application/pdf") whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) .thenReturn(MatrixCursor(columns).apply { addRow(values) }) val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -243,20 +209,19 @@ class PreviewDataProviderTest { val uri1 = Uri.parse("content://org.pkg.app/test.png") val uri2 = Uri.parse("content://org.pkg.app/test.jpg") val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE) - .apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList<Uri>().apply { - add(uri1) - add(uri2) - } - ) - } + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList<Uri>().apply { + add(uri1) + add(uri2) + } + ) + } whenever(contentResolver.getType(uri1)).thenReturn("image/png") whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg") val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -273,18 +238,17 @@ class PreviewDataProviderTest { whenever(contentResolver.getType(uri1)).thenReturn("image/png") whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE) - .apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList<Uri>().apply { - add(uri1) - add(uri2) - } - ) - } + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList<Uri>().apply { + add(uri1) + add(uri2) + } + ) + } val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -299,21 +263,20 @@ class PreviewDataProviderTest { val uri1 = Uri.parse("content://org.pkg.app/test.mp4") val uri2 = Uri.parse("content://org.pkg.app/test.pdf") val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE) - .apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList<Uri>().apply { - add(uri1) - add(uri2) - } - ) - } + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList<Uri>().apply { + add(uri1) + add(uri2) + } + ) + } whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4") whenever(contentResolver.getStreamTypes(uri1, "*/*")).thenReturn(arrayOf("image/png")) whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -327,20 +290,19 @@ class PreviewDataProviderTest { val uri1 = Uri.parse("content://org.pkg.app/test.html") val uri2 = Uri.parse("content://org.pkg.app/test.pdf") val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE) - .apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList<Uri>().apply { - add(uri1) - add(uri2) - } - ) - } + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList<Uri>().apply { + add(uri1) + add(uri2) + } + ) + } whenever(contentResolver.getType(uri1)).thenReturn("text/html") whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -348,4 +310,40 @@ class PreviewDataProviderTest { assertThat(testSubject.firstFileInfo?.previewUri).isNull() verify(contentResolver, times(2)).getType(any()) } + + @Test + fun test_imagePreviewFileInfoFlow_dataLoadedOnce() = + testScope.runTest { + val uri1 = Uri.parse("content://org.pkg.app/test.html") + val uri2 = Uri.parse("content://org.pkg.app/test.pdf") + val targetIntent = + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList<Uri>().apply { + add(uri1) + add(uri2) + } + ) + } + whenever(contentResolver.getType(uri1)).thenReturn("text/html") + whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") + whenever(contentResolver.getStreamTypes(uri1, "*/*")) + .thenReturn(arrayOf("text/html", "image/jpeg")) + whenever(contentResolver.getStreamTypes(uri2, "*/*")) + .thenReturn(arrayOf("application/pdf", "image/png")) + val testSubject = + PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + + val fileInfoListOne = testSubject.imagePreviewFileInfoFlow.toList() + val fileInfoListTwo = testSubject.imagePreviewFileInfoFlow.toList() + + assertThat(fileInfoListOne).hasSize(2) + assertThat(fileInfoListOne).containsAtLeastElementsIn(fileInfoListTwo).inOrder() + + verify(contentResolver, times(1)).getType(uri1) + verify(contentResolver, times(1)).getStreamTypes(uri1, "*/*") + verify(contentResolver, times(1)).getType(uri2) + verify(contentResolver, times(1)).getStreamTypes(uri2, "*/*") + } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt new file mode 100644 index 00000000..e7de0b7b --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -0,0 +1,166 @@ +/* + * 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.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.android.intentresolver.R.layout.chooser_grid +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class UnifiedContentPreviewUiTest { + private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) + private val actionFactory = + mock<ChooserContentPreviewUi.ActionFactory> { + whenever(createCustomActions()).thenReturn(emptyList()) + } + private val imageLoader = mock<ImageLoader>() + private val headlineGenerator = + mock<HeadlineGenerator> { + whenever(getImagesHeadline(anyInt())).thenReturn("Image Headline") + whenever(getVideosHeadline(anyInt())).thenReturn("Video Headline") + whenever(getFilesHeadline(anyInt())).thenReturn("Files Headline") + } + + private val context + get() = getInstrumentation().getContext() + + @Test + fun test_displayImagesWithoutUriMetadata_showImagesHeadline() { + testLoadingHeadline("image/*", files = null) + + verify(headlineGenerator, times(1)).getImagesHeadline(2) + } + + @Test + fun test_displayVideosWithoutUriMetadata_showImagesHeadline() { + testLoadingHeadline("video/*", files = null) + + verify(headlineGenerator, times(1)).getVideosHeadline(2) + } + + @Test + fun test_displayDocumentsWithoutUriMetadata_showImagesHeadline() { + testLoadingHeadline("application/pdf", files = null) + + verify(headlineGenerator, times(1)).getFilesHeadline(2) + } + + @Test + fun test_displayMixedContentWithoutUriMetadata_showImagesHeadline() { + testLoadingHeadline("*/*", files = null) + + verify(headlineGenerator, times(1)).getFilesHeadline(2) + } + + @Test + fun test_displayImagesWithUriMetadataSet_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("image/png").build(), + FileInfo.Builder(uri).withMimeType("image/jpeg").build(), + ) + testLoadingHeadline("image/*", files) + + verify(headlineGenerator, times(1)).getImagesHeadline(2) + } + + @Test + fun test_displayVideosWithUriMetadataSet_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + ) + testLoadingHeadline("video/*", files) + + verify(headlineGenerator, times(1)).getVideosHeadline(2) + } + + @Test + fun test_displayImagesAndVideosWithUriMetadataSet_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("image/png").build(), + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + ) + testLoadingHeadline("*/*", files) + + verify(headlineGenerator, times(1)).getFilesHeadline(2) + } + + @Test + fun test_displayDocumentsWithUriMetadataSet_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("application/pdf").build(), + FileInfo.Builder(uri).withMimeType("application/pdf").build(), + ) + testLoadingHeadline("application/pdf", files) + + verify(headlineGenerator, times(1)).getFilesHeadline(2) + } + + private fun testLoadingHeadline(intentMimeType: String, files: List<FileInfo>?) { + testScope.runTest { + val endMarker = FileInfo.Builder(Uri.EMPTY).build() + val emptySourceFlow = MutableSharedFlow<FileInfo>(replay = 1) + val testSubject = + UnifiedContentPreviewUi( + testScope, + /*isSingleImage=*/ false, + intentMimeType, + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + object : TransitionElementStatusCallback { + override fun onTransitionElementReady(name: String) = Unit + override fun onAllTransitionElementsReady() = Unit + }, + files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker }, + /*itemCount=*/ 2, + headlineGenerator + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = layoutInflater.inflate(chooser_grid, null, false) as ViewGroup + + testSubject.display(context.resources, LayoutInflater.from(context), gridLayout) + emptySourceFlow.tryEmit(endMarker) + } + } +} diff --git a/java/tests/src/com/android/intentresolver/dagger/TestActivityBinderModule.kt b/java/tests/src/com/android/intentresolver/dagger/TestActivityBinderModule.kt new file mode 100644 index 00000000..c08bc3b2 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/dagger/TestActivityBinderModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.dagger + +import android.app.Activity +import com.android.intentresolver.ChooserWrapperActivity +import com.android.intentresolver.ResolverWrapperActivity +import dagger.Binds +import dagger.Module +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +interface TestActivityBinderModule { + @Binds + @IntoMap + @ClassKey(ResolverWrapperActivity::class) + @ActivityScope + fun resolverWrapperActivity(activity: ResolverWrapperActivity): Activity + + @Binds + @IntoMap + @ClassKey(ChooserWrapperActivity::class) + @ActivityScope + fun chooserWrapperActivity(activity: ChooserWrapperActivity): Activity +} diff --git a/java/tests/src/com/android/intentresolver/dagger/TestActivityComponent.kt b/java/tests/src/com/android/intentresolver/dagger/TestActivityComponent.kt new file mode 100644 index 00000000..4416c852 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/dagger/TestActivityComponent.kt @@ -0,0 +1,30 @@ +/* + * 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.dagger + +import dagger.Subcomponent + +@ActivityScope +@Subcomponent( + modules = [ActivityModule::class, ActivityBinderModule::class, TestActivityBinderModule::class] +) +interface TestActivityComponent : ActivityComponent { + @Subcomponent.Factory + interface Factory : ActivityComponent.Factory { + override fun create(): TestActivityComponent + } +} diff --git a/java/tests/src/com/android/intentresolver/dagger/TestApplicationComponent.kt b/java/tests/src/com/android/intentresolver/dagger/TestApplicationComponent.kt new file mode 100644 index 00000000..224c46c6 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/dagger/TestApplicationComponent.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.dagger + +import android.app.Application +import dagger.BindsInstance +import dagger.Component +import javax.inject.Singleton + +@Singleton +@Component(modules = [TestApplicationModule::class]) +interface TestApplicationComponent : ApplicationComponent { + @Component.Builder + interface Builder : ApplicationComponent.Builder { + @BindsInstance + override fun application(application: Application): TestApplicationComponent.Builder + + override fun build(): TestApplicationComponent + } +} diff --git a/java/tests/src/com/android/intentresolver/dagger/TestApplicationModule.kt b/java/tests/src/com/android/intentresolver/dagger/TestApplicationModule.kt new file mode 100644 index 00000000..714748d2 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/dagger/TestApplicationModule.kt @@ -0,0 +1,38 @@ +/* + * 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.dagger + +import dagger.Binds +import dagger.Module +import javax.inject.Singleton + +@Module( + subcomponents = [TestActivityComponent::class, TestViewModelComponent::class], + includes = [ReceiverBinderModule::class, CoroutinesModule::class] +) +interface TestApplicationModule : ApplicationModule { + + /** Required to support field injection of [InjectedAppComponentFactory] */ + @Binds + @Singleton + fun activityComponent(component: TestActivityComponent.Factory): ActivityComponent.Factory + + /** Required to support injection into Activity instances */ + @Binds + @Singleton + fun viewModelComponent(component: TestViewModelComponent.Builder): ViewModelComponent.Builder +} diff --git a/java/tests/src/com/android/intentresolver/dagger/TestViewModelComponent.kt b/java/tests/src/com/android/intentresolver/dagger/TestViewModelComponent.kt new file mode 100644 index 00000000..539b3f36 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/dagger/TestViewModelComponent.kt @@ -0,0 +1,29 @@ +/* + * 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.dagger + +import dagger.Subcomponent + +/** A ViewModelComponent for tests which replaces ViewModelModule -> TestViewModelModule */ +@ViewModelScope +@Subcomponent(modules = [TestViewModelModule::class, ViewModelBinderModule::class]) +interface TestViewModelComponent : ViewModelComponent { + @Subcomponent.Builder + interface Builder : ViewModelComponent.Builder { + override fun build(): TestViewModelComponent + } +} diff --git a/java/tests/src/com/android/intentresolver/dagger/TestViewModelModule.kt b/java/tests/src/com/android/intentresolver/dagger/TestViewModelModule.kt new file mode 100644 index 00000000..28f4fa73 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/dagger/TestViewModelModule.kt @@ -0,0 +1,6 @@ +package com.android.intentresolver.dagger + +import dagger.Module + +/** Provides bindings shared among components within [@ViewModelScope][ViewModelScope]. */ +@Module abstract class TestViewModelModule diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/logging/EventLogTest.java index aa42c24c..17452774 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java +++ b/java/tests/src/com/android/intentresolver/logging/EventLogTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 The Android Open Source Project + * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.logging; import static com.google.common.truth.Truth.assertThat; @@ -32,10 +32,10 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import android.content.Intent; import android.metrics.LogMaker; -import com.android.intentresolver.ChooserActivityLogger.FrameworkStatsLogger; -import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent; -import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent; -import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent; +import com.android.intentresolver.logging.EventLog.FrameworkStatsLogger; +import com.android.intentresolver.logging.EventLog.SharesheetStandardEvent; +import com.android.intentresolver.logging.EventLog.SharesheetStartedEvent; +import com.android.intentresolver.logging.EventLog.SharesheetTargetSelectedEvent; import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.logging.InstanceId; import com.android.internal.logging.MetricsLogger; @@ -53,17 +53,17 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) -public final class ChooserActivityLoggerTest { +public final class EventLogTest { @Mock private UiEventLogger mUiEventLog; @Mock private FrameworkStatsLogger mFrameworkLog; @Mock private MetricsLogger mMetricsLogger; - private ChooserActivityLogger mChooserLogger; + private EventLog mChooserLogger; @Before public void setUp() { //Mockito.reset(mUiEventLog, mFrameworkLog, mMetricsLogger); - mChooserLogger = new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger); + mChooserLogger = new EventLog(mUiEventLog, mFrameworkLog, mMetricsLogger); } @After @@ -151,7 +151,7 @@ public final class ChooserActivityLoggerTest { @Test public void testLogShareTargetSelected() { - final int targetType = ChooserActivityLogger.SELECTION_TYPE_SERVICE; + final int targetType = EventLog.SELECTION_TYPE_SERVICE; final String packageName = "com.test.foo"; final int positionPicked = 123; final int directTargetAlsoRanked = -1; @@ -189,7 +189,7 @@ public final class ChooserActivityLoggerTest { @Test public void testLogActionSelected() { - mChooserLogger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); + mChooserLogger.logActionSelected(EventLog.SELECTION_TYPE_COPY); verify(mFrameworkLog).write( eq(FrameworkStatsLog.RANKING_SELECTED), @@ -320,10 +320,10 @@ public final class ChooserActivityLoggerTest { @Test public void testDifferentLoggerInstancesUseDifferentInstanceIds() { ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class); - ChooserActivityLogger chooserLogger2 = - new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger); + EventLog chooserLogger2 = + new EventLog(mUiEventLog, mFrameworkLog, mMetricsLogger); - final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY; + final int targetType = EventLog.SELECTION_TYPE_COPY; final String packageName = "com.test.foo"; final int positionPicked = 123; final int directTargetAlsoRanked = -1; @@ -370,7 +370,7 @@ public final class ChooserActivityLoggerTest { ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class); ArgumentCaptor<InstanceId> idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class); - final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY; + final int targetType = EventLog.SELECTION_TYPE_COPY; final String packageName = "com.test.foo"; final int positionPicked = 123; final int directTargetAlsoRanked = -1; @@ -403,20 +403,20 @@ public final class ChooserActivityLoggerTest { @Test public void testTargetSelectionCategories() { - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_SERVICE)) + assertThat(EventLog.getTargetSelectionCategory( + EventLog.SELECTION_TYPE_SERVICE)) .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_APP)) + assertThat(EventLog.getTargetSelectionCategory( + EventLog.SELECTION_TYPE_APP)) .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_STANDARD)) + assertThat(EventLog.getTargetSelectionCategory( + EventLog.SELECTION_TYPE_STANDARD)) .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_COPY)).isEqualTo(0); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_NEARBY)).isEqualTo(0); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_EDIT)).isEqualTo(0); + assertThat(EventLog.getTargetSelectionCategory( + EventLog.SELECTION_TYPE_COPY)).isEqualTo(0); + assertThat(EventLog.getTargetSelectionCategory( + EventLog.SELECTION_TYPE_NEARBY)).isEqualTo(0); + assertThat(EventLog.getTargetSelectionCategory( + EventLog.SELECTION_TYPE_EDIT)).isEqualTo(0); } } diff --git a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt index e65cba5f..4f4223c0 100644 --- a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -47,7 +48,6 @@ class BatchPreviewLoaderTest { private val dispatcher = UnconfinedTestDispatcher() private val testScope = CoroutineScope(dispatcher) private val onCompletion = mock<() -> Unit>() - private val onReset = mock<(Int) -> Unit>() private val onUpdate = mock<(List<Preview>) -> Unit>() @Before @@ -71,8 +71,7 @@ class BatchPreviewLoaderTest { BatchPreviewLoader( imageLoader, previews(uriOne, uriTwo), - 0, - onReset, + totalItemCount = 2, onUpdate, onCompletion ) @@ -80,7 +79,6 @@ class BatchPreviewLoaderTest { dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(2) val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri } assertThat(list).containsExactly(uriOne, uriTwo).inOrder() } @@ -96,8 +94,7 @@ class BatchPreviewLoaderTest { BatchPreviewLoader( imageLoader, previews(uriOne, uriTwo, uriThree), - 0, - onReset, + totalItemCount = 3, onUpdate, onCompletion ) @@ -105,7 +102,6 @@ class BatchPreviewLoaderTest { dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(3) val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri } assertThat(list).containsExactly(uriOne, uriThree).inOrder() } @@ -126,12 +122,11 @@ class BatchPreviewLoaderTest { } imageLoader.setUriLoadingOrder(*loadingOrder) val testSubject = - BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion) + BatchPreviewLoader(imageLoader, previews(*uris), uris.size, onUpdate, onCompletion) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(uris.size) val list = captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) } .fold(ArrayList<Preview>()) { acc, update -> acc.apply { addAll(update) } } @@ -156,12 +151,11 @@ class BatchPreviewLoaderTest { val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) } imageLoader.setUriLoadingOrder(*loadingOrder) val testSubject = - BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion) + BatchPreviewLoader(imageLoader, previews(*uris), uris.size, onUpdate, onCompletion) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(uris.size) val list = captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) } .fold(ArrayList<Preview>()) { acc, update -> acc.apply { addAll(update) } } @@ -174,9 +168,11 @@ class BatchPreviewLoaderTest { private fun fail(uri: Uri) = uri to false private fun succeed(uri: Uri) = uri to true private fun previews(vararg uris: Uri) = - uris.fold(ArrayList<Preview>(uris.size)) { acc, uri -> - acc.apply { add(Preview(PreviewType.Image, uri, editAction = null)) } - } + uris + .fold(ArrayList<Preview>(uris.size)) { acc, uri -> + acc.apply { add(Preview(PreviewType.Image, uri, editAction = null)) } + } + .asFlow() } private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Boolean) -> Bitmap? { diff --git a/proguard.flags b/proguard.flags new file mode 100644 index 00000000..5541c3ff --- /dev/null +++ b/proguard.flags @@ -0,0 +1,2 @@ +# Class referenced from xml drawable +-keep class com.android.intentresolver.SimpleIconFactory$FixedScaleDrawable |