diff options
52 files changed, 1664 insertions, 991 deletions
@@ -63,7 +63,7 @@ android_library { "java/res", ], - manifest: "AndroidManifest.xml", + manifest: "AndroidManifest-lib.xml", static_libs: [ "androidx.annotation_annotation", @@ -86,6 +86,10 @@ android_library { lint: { strict_updatability_linting: false, }, + + optimize: { + proguard_flags_files: ["proguard.flags"], + }, } android_app { @@ -93,6 +97,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..57ea497b 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest-app.xml @@ -22,22 +22,6 @@ 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" /> - <application android:hardwareAccelerated="true" android:label="@string/app_label" @@ -46,15 +30,12 @@ android:requiredForAllUsers="true" android:supportsRtl="true"> - <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 +46,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/drawable/ic_file_video.xml b/java/res/drawable/ic_file_video.xml index ec6e290b..3156c55a 100644 --- a/java/res/drawable/ic_file_video.xml +++ b/java/res/drawable/ic_file_video.xml @@ -23,5 +23,6 @@ <path android:fillColor="@android:color/white" - android:pathData="m4,20c-0.55,0 -1.02,-0.19 -1.42,-0.57c-0.39,-0.4 -0.58,-0.88 -0.58,-1.43l0,-12c0,-0.55 0.19,-1.02 0.58,-1.4c0.39,-0.4 0.87,-0.6 1.42,-0.6l12,0c0.55,0 1.02,0.2 1.4,0.6c0.4,0.38 0.6,0.85 0.6,1.4l0,4.5l4,-4l0,11l-4,-4l0,4.5c0,0.55 -0.2,1.03 -0.6,1.43c-0.38,0.38 -0.85,0.57 -1.4,0.57l-12,0zm0,-2l12,0l0,-12l-12,0l0,12zm0,0l0,-12l0,12z"/> + android:pathData="M2,12C2,6.48 6.48,2 12,2C17.52,2 22,6.48 22,12C22,17.52 17.52,22 12,22C6.48,22 2,17.52 2,12ZM16,12L10,7.5V16.5L16,12Z" + android:fillType="evenOdd"/> </vector> diff --git a/java/res/layout-h480dp/image_preview_loading_item.xml b/java/res/layout-h480dp/image_preview_loading_item.xml new file mode 100644 index 00000000..85020e9a --- /dev/null +++ b/java/res/layout-h480dp/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/layout-h480dp/image_preview_other_item.xml b/java/res/layout-h480dp/image_preview_other_item.xml new file mode 100644 index 00000000..470f105a --- /dev/null +++ b/java/res/layout-h480dp/image_preview_other_item.xml @@ -0,0 +1,31 @@ +<!-- + ~ 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" + android:layout_width="@dimen/chooser_preview_image_width" + android:layout_height="@dimen/chooser_preview_image_height_tall"> + + <TextView + android:id="@+id/label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:drawableTop="@drawable/ic_file_copy" + android:drawablePadding="8dp" + android:textAppearance="@style/TextAppearance.ChooserDefault" /> + +</FrameLayout>
\ No newline at end of file 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_image_item.xml b/java/res/layout/image_preview_image_item.xml index 3f534831..442e9345 100644 --- a/java/res/layout/image_preview_image_item.xml +++ b/java/res/layout/image_preview_image_item.xml @@ -18,8 +18,8 @@ <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="46dp" - android:layout_height="46dp"> + android:layout_width="@dimen/chooser_preview_image_height_tall" + android:layout_height="@dimen/chooser_preview_image_height_tall"> <com.android.intentresolver.widget.RoundedRectImageView android:id="@+id/image" 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..a8a8f264 --- /dev/null +++ b/java/res/layout/image_preview_loading_item.xml @@ -0,0 +1,33 @@ +<!-- + ~ 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="wrap_content" + android:layout_height="@dimen/chooser_preview_image_height_tall" + android:padding="8dp"> + + <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/layout/image_preview_other_item.xml b/java/res/layout/image_preview_other_item.xml index 07f87e3a..db458656 100644 --- a/java/res/layout/image_preview_other_item.xml +++ b/java/res/layout/image_preview_other_item.xml @@ -16,16 +16,18 @@ <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="@dimen/chooser_preview_image_width" - android:layout_height="@dimen/chooser_preview_image_height_tall"> + android:layout_width="wrap_content" + android:layout_height="@dimen/chooser_preview_image_height_tall" + android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half"> <TextView android:id="@+id/label" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:drawableTop="@drawable/ic_file_copy" - android:drawablePadding="8dp" + android:drawableStart="@drawable/ic_file_copy" + android:drawablePadding="4dp" + android:gravity="bottom" android:textAppearance="@style/TextAppearance.ChooserDefault" /> </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/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..b27f054e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -27,7 +27,6 @@ import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_S import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; import android.annotation.IntDef; -import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityManager; @@ -66,9 +65,6 @@ import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; import android.view.WindowInsets; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.LinearInterpolator; import android.widget.TextView; import androidx.annotation.MainThread; @@ -92,6 +88,7 @@ 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; @@ -191,7 +188,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; @@ -224,6 +221,13 @@ public class ChooserActivity extends ResolverActivity implements private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>(); private boolean mExcludeSharedText = false; + /** + * When we intend to finish the activity with a shared element transition, we can't immediately + * finish() when the transition is invoked, as the receiving end may not be able to start the + * animation and the UI breaks if this takes too long. Instead we defer finishing until onStop + * in order to wait for the transition to begin. + */ + private boolean mFinishWhenStopped = false; public ChooserActivity() {} @@ -233,7 +237,7 @@ public class ChooserActivity extends ResolverActivity implements final long intentReceivedTime = System.currentTimeMillis(); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); - getChooserActivityLogger().logSharesheetTriggered(); + getEventLog().logSharesheetTriggered(); mFeatureFlagRepository = createFeatureFlagRepository(); mIntegratedDeviceComponents = getIntegratedDeviceComponents(); @@ -283,10 +287,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 +304,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 +324,7 @@ public class ChooserActivity extends ResolverActivity implements mResolverDrawerLayout.setOnCollapsedChangedListener( isCollapsed -> { mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed); - getChooserActivityLogger().logSharesheetExpansionChanged(isCollapsed); + getEventLog().logSharesheetExpansionChanged(isCollapsed); }); } @@ -330,7 +332,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 +551,7 @@ public class ChooserActivity extends ResolverActivity implements if (shouldShowStickyContentPreview() || mChooserMultiProfilePagerAdapter .getCurrentRootAdapter().getSystemRowCount() != 0) { - getChooserActivityLogger().logActionShareWithPreview( + getEventLog().logActionShareWithPreview( mChooserContentPreviewUi.getPreferredContentPreview()); } return postRebuildListInternal(rebuildCompleted); @@ -614,8 +616,7 @@ public class ChooserActivity extends ResolverActivity implements protected void onResume() { super.onResume(); Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); - maybeCancelFinishAnimation(); - + mFinishWhenStopped = false; mRefinementManager.onActivityResume(); } @@ -716,7 +717,8 @@ public class ChooserActivity extends ResolverActivity implements super.onStop(); mRefinementManager.onActivityStop(isChangingConfigurations()); - if (maybeCancelFinishAnimation()) { + if (mFinishWhenStopped) { + mFinishWhenStopped = false; finish(); } } @@ -848,9 +850,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 +883,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 +891,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 +925,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 +942,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 +995,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 +1119,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 +1129,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 +1259,7 @@ public class ChooserActivity extends ResolverActivity implements targetIntent, this, context.getPackageManager(), - getChooserActivityLogger(), + getEventLog(), chooserRequest, maxTargetsPerRow, initialIntentsUserSpace, @@ -1279,7 +1283,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 +1292,7 @@ public class ChooserActivity extends ResolverActivity implements getTargetIntent(), getReferrerPackageName(), null, - getChooserActivityLogger(), + getEventLog(), getResolverRankerServiceUserHandleList(userHandle), getIntegratedDeviceComponents().getNearbySharingComponent()); } @@ -1313,7 +1317,7 @@ public class ChooserActivity extends ResolverActivity implements this, mChooserRequest, mIntegratedDeviceComponents, - getChooserActivityLogger(), + getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, this::getFirstVisibleImgPreviewView, new ChooserActionFactory.ActionActivityStarter() { @@ -1330,7 +1334,10 @@ public class ChooserActivity extends ResolverActivity implements ChooserActivity.this, sharedElement, sharedElementName); safelyStartActivityAsUser( targetInfo, getPersonalProfileUserHandle(), options.toBundle()); - startFinishAnimation(); + // Can't finish right away because the shared element transition may not + // be ready to start. + mFinishWhenStopped = true; + } }, (status) -> { @@ -1528,7 +1535,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 +1582,7 @@ public class ChooserActivity extends ResolverActivity implements } logDirectShareTargetReceived(userHandle); sendVoiceChoicesIfNeeded(); - getChooserActivityLogger().logSharesheetDirectLoadComplete(); + getEventLog().logSharesheetDirectLoadComplete(); } private void setupScrollListener() { @@ -1715,25 +1722,6 @@ public class ChooserActivity extends ResolverActivity implements contentPreviewContainer.setVisibility(View.GONE); } - private void startFinishAnimation() { - View rootView = findRootView(); - if (rootView != null) { - rootView.startAnimation(new FinishAnimation(this, rootView)); - } - } - - private boolean maybeCancelFinishAnimation() { - View rootView = findRootView(); - Animation animation = (rootView == null) ? null : rootView.getAnimation(); - if (animation instanceof FinishAnimation) { - boolean hasEnded = animation.hasEnded(); - animation.cancel(); - rootView.clearAnimation(); - return !hasEnded; - } - return false; - } - private View findRootView() { if (mContentView == null) { mContentView = findViewById(android.R.id.content); @@ -1814,74 +1802,9 @@ public class ChooserActivity extends ResolverActivity implements } } - /** - * Used in combination with the scene transition when launching the image editor - */ - private static class FinishAnimation extends AlphaAnimation implements - Animation.AnimationListener { - @Nullable - private Activity mActivity; - @Nullable - private View mRootView; - private final float mFromAlpha; - - FinishAnimation(@NonNull Activity activity, @NonNull View rootView) { - super(rootView.getAlpha(), 0.0f); - mActivity = activity; - mRootView = rootView; - mFromAlpha = rootView.getAlpha(); - setInterpolator(new LinearInterpolator()); - long duration = activity.getWindow().getTransitionBackgroundFadeDuration(); - setDuration(duration); - // The scene transition animation looks better when it's not overlapped with this - // fade-out animation thus the delay. - // It is most likely that the image editor will cause this activity to stop and this - // animation will be cancelled in the background without running (i.e. we'll animate - // only when this activity remains partially visible after the image editor launch). - setStartOffset(duration); - super.setAnimationListener(this); - } - - @Override - public void setAnimationListener(AnimationListener listener) { - throw new UnsupportedOperationException(); - } - - @Override - public void cancel() { - if (mRootView != null) { - mRootView.setAlpha(mFromAlpha); - } - cleanup(); - super.cancel(); - } - - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationEnd(Animation animation) { - Activity activity = mActivity; - cleanup(); - if (activity != null) { - activity.finish(); - } - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - - private void cleanup() { - mActivity = null; - mRootView = null; - } - } - @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/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 57871532..35c7e897 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,6 +120,7 @@ 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; @@ -143,7 +145,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 +341,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 +407,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 +430,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 +486,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 +1074,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 +1085,7 @@ public class ResolverActivity extends FragmentActivity implements context, payloadIntents, initialIntents, - rList, + resolutionList, filterLastUsed, createListController(userHandle), userHandle, @@ -1127,6 +1158,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 +1179,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 +1207,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 +1234,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 +1244,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 +1402,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 +1462,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 +1662,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 +2279,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 +2417,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/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/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..90c7fb7a 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -19,18 +19,23 @@ android_test { static_libs: [ "IntentResolver-core", + "androidx.test.core", "androidx.test.rules", "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", + "androidx.lifecycle_lifecycle-runtime-testing", "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/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..8608cf72 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -40,6 +40,7 @@ import com.android.intentresolver.chooser.TargetInfo; 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; @@ -89,7 +90,7 @@ public class ChooserWrapperActivity targetIntent, this, packageManager, - getChooserActivityLogger(), + getEventLog(), chooserRequest, maxTargetsPerRow, userHandle, @@ -205,8 +206,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/EnterTransitionAnimationDelegateTest.kt b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt index 9ea9dfa7..c7d20000 100644 --- a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt +++ b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt @@ -21,6 +21,7 @@ import android.view.View import android.view.Window import androidx.activity.ComponentActivity import androidx.lifecycle.Lifecycle +import androidx.lifecycle.testing.TestLifecycleOwner import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -44,35 +45,34 @@ class EnterTransitionAnimationDelegateTest { private val dispatcher = StandardTestDispatcher(scheduler) private val lifecycleOwner = TestLifecycleOwner() - private val transitionTargetView = mock<View> { - // avoid the request-layout path in the delegate - whenever(isInLayout).thenReturn(true) - } + private val transitionTargetView = + mock<View> { + // avoid the request-layout path in the delegate + whenever(isInLayout).thenReturn(true) + } private val windowMock = mock<Window>() - private val resourcesMock = mock<Resources> { - whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS) - } - private val activity = mock<ComponentActivity> { - whenever(lifecycle).thenReturn(lifecycleOwner.lifecycle) - whenever(resources).thenReturn(resourcesMock) - whenever(isActivityTransitionRunning).thenReturn(true) - whenever(window).thenReturn(windowMock) - } - - private val testSubject = EnterTransitionAnimationDelegate(activity) { - transitionTargetView - } + private val resourcesMock = + mock<Resources> { whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS) } + private val activity = + mock<ComponentActivity> { + whenever(lifecycle).thenReturn(lifecycleOwner.lifecycle) + whenever(resources).thenReturn(resourcesMock) + whenever(isActivityTransitionRunning).thenReturn(true) + whenever(window).thenReturn(windowMock) + } + + private val testSubject = EnterTransitionAnimationDelegate(activity) { transitionTargetView } @Before fun setup() { Dispatchers.setMain(dispatcher) - lifecycleOwner.state = Lifecycle.State.CREATED + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) } @After fun cleanup() { - lifecycleOwner.state = Lifecycle.State.DESTROYED + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) Dispatchers.resetMain() } 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/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 deleted file mode 100644 index 7e588f98..00000000 --- a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry - -internal class TestLifecycleOwner : LifecycleOwner { - private val lifecycleRegistry = LifecycleRegistry.createUnsafe(this) - - override val lifecycle: Lifecycle get() = lifecycleRegistry - - var state: Lifecycle.State - get() = lifecycle.currentState - set(value) { - lifecycleRegistry.currentState = value - } -}
\ No newline at end of file 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..dab1a956 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 androidx.lifecycle.testing.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..fe13a215 --- /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.lifecycle.testing.TestLifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.android.intentresolver.R +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/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt index 6e57c289..b5fd1fa6 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt @@ -22,7 +22,7 @@ import android.net.Uri import android.util.Size import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope -import com.android.intentresolver.TestLifecycleOwner +import androidx.lifecycle.testing.TestLifecycleOwner import com.android.intentresolver.any import com.android.intentresolver.anyOrNull import com.android.intentresolver.mock @@ -78,7 +78,7 @@ class ImagePreviewImageLoaderTest { @Before fun setup() { Dispatchers.setMain(dispatcher) - lifecycleOwner.state = Lifecycle.State.CREATED + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) // create test subject after we've updated the lifecycle dispatcher testSubject = ImagePreviewImageLoader( @@ -91,7 +91,7 @@ class ImagePreviewImageLoaderTest { @After fun cleanup() { - lifecycleOwner.state = Lifecycle.State.DESTROYED + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) Dispatchers.resetMain() } @@ -164,7 +164,7 @@ class ImagePreviewImageLoaderTest { @Test(expected = CancellationException::class) fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest { - lifecycleOwner.state = Lifecycle.State.DESTROYED + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) testSubject(uriOne) } @@ -181,7 +181,7 @@ class ImagePreviewImageLoaderTest { ) coroutineScope { val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) } - lifecycleOwner.state = Lifecycle.State.DESTROYED + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) scheduler.advanceUntilIdle() deferred.await() } 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/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/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index 742aac71..9b4a8057 100644 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -27,8 +27,8 @@ import android.content.pm.ShortcutManager import android.os.UserHandle import android.os.UserManager import androidx.lifecycle.Lifecycle +import androidx.lifecycle.testing.TestLifecycleOwner import androidx.test.filters.SmallTest -import com.android.intentresolver.TestLifecycleOwner import com.android.intentresolver.any import com.android.intentresolver.argumentCaptor import com.android.intentresolver.capture @@ -38,6 +38,7 @@ import com.android.intentresolver.createShareShortcutInfo import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock import com.android.intentresolver.whenever +import java.util.function.Consumer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineScheduler @@ -56,28 +57,31 @@ import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify -import java.util.function.Consumer @OptIn(ExperimentalCoroutinesApi::class) @SmallTest class ShortcutLoaderTest { - private val appInfo = ApplicationInfo().apply { - enabled = true - flags = 0 - } - private val pm = mock<PackageManager> { - whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo) - } - private val userManager = mock<UserManager> { - whenever(isUserRunning(any<UserHandle>())).thenReturn(true) - whenever(isUserUnlocked(any<UserHandle>())).thenReturn(true) - whenever(isQuietModeEnabled(any<UserHandle>())).thenReturn(false) - } - private val context = mock<Context> { - whenever(packageManager).thenReturn(pm) - whenever(createContextAsUser(any(), anyInt())).thenReturn(this) - whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) - } + private val appInfo = + ApplicationInfo().apply { + enabled = true + flags = 0 + } + private val pm = + mock<PackageManager> { + whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo) + } + private val userManager = + mock<UserManager> { + whenever(isUserRunning(any<UserHandle>())).thenReturn(true) + whenever(isUserUnlocked(any<UserHandle>())).thenReturn(true) + whenever(isQuietModeEnabled(any<UserHandle>())).thenReturn(false) + } + private val context = + mock<Context> { + whenever(packageManager).thenReturn(pm) + whenever(createContextAsUser(any(), anyInt())).thenReturn(this) + whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) + } private val scheduler = TestCoroutineScheduler() private val dispatcher = UnconfinedTestDispatcher(scheduler) private val lifecycleOwner = TestLifecycleOwner() @@ -85,47 +89,48 @@ class ShortcutLoaderTest { private val appPredictor = mock<ShortcutLoader.AppPredictorProxy>() private val callback = mock<Consumer<ShortcutLoader.Result>>() private val componentName = ComponentName("pkg", "Class") - private val appTarget = mock<DisplayResolveInfo> { - whenever(resolvedComponentName).thenReturn(componentName) - } + private val appTarget = + mock<DisplayResolveInfo> { whenever(resolvedComponentName).thenReturn(componentName) } private val appTargets = arrayOf(appTarget) private val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) @Before fun setup() { Dispatchers.setMain(dispatcher) - lifecycleOwner.state = Lifecycle.State.CREATED + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) } @After fun cleanup() { - lifecycleOwner.state = Lifecycle.State.DESTROYED + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) Dispatchers.resetMain() } @Test fun test_loadShortcutsWithAppPredictor_resultIntegrity() { - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) + val testSubject = + ShortcutLoader( + context, + lifecycleOwner.lifecycle, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) testSubject.updateAppTargets(appTargets) val matchingAppTarget = createAppTarget(matchingShortcutInfo) - val shortcuts = listOf( - matchingAppTarget, - // an AppTarget that does not belong to any resolved application; should be ignored - createAppTarget( - createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + val shortcuts = + listOf( + matchingAppTarget, + // an AppTarget that does not belong to any resolved application; should be ignored + createAppTarget( + createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) ) - ) val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() verify(appPredictor, atLeastOnce()) .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) @@ -155,25 +160,28 @@ class ShortcutLoaderTest { @Test fun test_loadShortcutsWithShortcutManager_resultIntegrity() { - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock<ShortcutManager> { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - null, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) + val testSubject = + ShortcutLoader( + context, + lifecycleOwner.lifecycle, + null, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) testSubject.updateAppTargets(appTargets) @@ -200,25 +208,28 @@ class ShortcutLoaderTest { @Test fun test_appPredictorReturnsEmptyList_fallbackToShortcutManager() { - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock<ShortcutManager> { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) + val testSubject = + ShortcutLoader( + context, + lifecycleOwner.lifecycle, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) testSubject.updateAppTargets(appTargets) @@ -251,27 +262,30 @@ class ShortcutLoaderTest { @Test fun test_appPredictor_requestPredictionUpdateFailure_fallbackToShortcutManager() { - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock<ShortcutManager> { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) whenever(appPredictor.requestPredictionUpdate()) .thenThrow(IllegalStateException("Test exception")) - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) + val testSubject = + ShortcutLoader( + context, + lifecycleOwner.lifecycle, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) testSubject.updateAppTargets(appTargets) @@ -317,25 +331,28 @@ class ShortcutLoaderTest { @Test fun test_ShortcutLoader_noResultsWithoutAppTargets() { - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock<ShortcutManager> { + whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + } whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - null, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) + val testSubject = + ShortcutLoader( + context, + lifecycleOwner.lifecycle, + null, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) verify(shortcutManager, times(1)).getShareTargets(any()) verify(callback, never()).accept(any()) @@ -366,7 +383,7 @@ class ShortcutLoaderTest { verify(appPredictor, never()).unregisterPredictionUpdates(any()) - lifecycleOwner.state = Lifecycle.State.DESTROYED + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) verify(appPredictor, times(1)).unregisterPredictionUpdates(any()) } @@ -412,19 +429,20 @@ class ShortcutLoaderTest { whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) } - whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager) val appPredictor = mock<ShortcutLoader.AppPredictorProxy>() val callback = mock<Consumer<ShortcutLoader.Result>>() - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - userHandle, - false, - intentFilter, - dispatcher, - callback - ) + val testSubject = + ShortcutLoader( + context, + lifecycleOwner.lifecycle, + appPredictor, + userHandle, + false, + intentFilter, + dispatcher, + callback + ) testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock())) @@ -442,19 +460,20 @@ class ShortcutLoaderTest { whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) } - whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); + whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager) val appPredictor = mock<ShortcutLoader.AppPredictorProxy>() val callback = mock<Consumer<ShortcutLoader.Result>>() - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - userHandle, - true, - intentFilter, - dispatcher, - callback - ) + val testSubject = + ShortcutLoader( + context, + lifecycleOwner.lifecycle, + appPredictor, + userHandle, + true, + intentFilter, + dispatcher, + callback + ) testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock())) 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 |