diff options
Diffstat (limited to 'java')
96 files changed, 7848 insertions, 3514 deletions
diff --git a/java/res/anim/slide_in_right.xml b/java/res/anim/slide_in_right.xml new file mode 100644 index 00000000..3d3cd919 --- /dev/null +++ b/java/res/anim/slide_in_right.xml @@ -0,0 +1,22 @@ +<!-- + ~ 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. + --> + +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromXDelta="100%p" android:toXDelta="0" + android:duration="@android:integer/config_mediumAnimTime"/> + <alpha android:fromAlpha="0.0" android:toAlpha="1.0" + android:duration="@android:integer/config_mediumAnimTime" /> +</set>
\ No newline at end of file diff --git a/java/res/anim/slide_out_left.xml b/java/res/anim/slide_out_left.xml new file mode 100644 index 00000000..b3471518 --- /dev/null +++ b/java/res/anim/slide_out_left.xml @@ -0,0 +1,20 @@ +<!-- + ~ 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. + --> + +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromXDelta="0" android:toXDelta="-100%p" + android:duration="@android:integer/config_mediumAnimTime"/> +</set>
\ No newline at end of file diff --git a/java/res/layout/chooser_dialog.xml b/java/res/layout/chooser_dialog.xml index e31712c7..19ead35a 100644 --- a/java/res/layout/chooser_dialog.xml +++ b/java/res/layout/chooser_dialog.xml @@ -18,6 +18,7 @@ <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:id="@+id/chooser_dialog_content" android:background="@drawable/chooser_dialog_background" android:orientation="vertical" android:paddingBottom="8dp" diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index e98c3273..095e5d62 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -65,9 +65,19 @@ android:ellipsize="middle" android:gravity="start|top" android:paddingRight="24dp" - android:singleLine="true"/> + android:singleLine="true" + android:textAppearance="@style/TextAppearance.ChooserDefault" /> </LinearLayout> + <TextView + android:id="@+id/reselection_action" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + android:text="@string/select_files" + android:gravity="center" + style="@style/ReselectionAction" /> + <ViewStub android:id="@+id/action_row_stub" android:layout_width="match_parent" diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 5c324140..792b7d4d 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -16,7 +16,6 @@ * limitations under the License. */ --> -<!-- Layout Option: Supporting up to 3 images for preview --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" @@ -25,13 +24,49 @@ android:orientation="vertical" android:background="?android:attr/colorBackground"> - <com.android.intentresolver.widget.ImagePreviewView - android:id="@androidprv:id/content_preview_image_area" + <CheckBox + android:id="@+id/include_text_action" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center_horizontal" - android:paddingBottom="@dimen/chooser_view_spacing" - android:background="?android:attr/colorBackground" /> + android:layout_gravity="end" + android:layout_marginEnd="@dimen/chooser_edge_margin_normal" + android:visibility="gone" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_horizontal" + android:layout_marginBottom="@dimen/chooser_view_spacing"> + + <ViewStub + android:id="@+id/image_preview_stub" + android:inflatedId="@androidprv:id/content_preview_image_area" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + <TextView + android:id="@androidprv:id/content_preview_text" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_gravity="center_vertical" + android:paddingEnd="@dimen/chooser_edge_margin_normal" + android:maxLines="6" + android:ellipsize="end" + android:linksClickable="false" + android:visibility="gone" + android:textAppearance="@style/TextAppearance.ChooserDefault" /> + </LinearLayout> + + <TextView + android:id="@+id/reselection_action" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + android:text="@string/select_images" + android:gravity="center" + style="@style/ReselectionAction" /> <ViewStub android:id="@+id/action_row_stub" @@ -39,4 +74,3 @@ android:layout_height="wrap_content" /> </LinearLayout> - diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index db7282e3..49a2edff 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -52,6 +52,15 @@ </RelativeLayout> + <TextView + android:id="@+id/reselection_action" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + android:text="@string/select_text" + android:gravity="center" + style="@style/ReselectionAction" /> + <ViewStub android:id="@+id/action_row_stub" android:layout_width="match_parent" diff --git a/java/res/layout/chooser_image_preview_view.xml b/java/res/layout/chooser_image_preview_view.xml new file mode 100644 index 00000000..e81349c7 --- /dev/null +++ b/java/res/layout/chooser_image_preview_view.xml @@ -0,0 +1,26 @@ +<?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. + --> + +<com.android.intentresolver.widget.ChooserImagePreviewView + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:paddingStart="@dimen/chooser_edge_margin_normal" + android:paddingEnd="@dimen/chooser_edge_margin_normal" + android:paddingBottom="@dimen/chooser_view_spacing" + android:background="?android:attr/colorBackground" /> diff --git a/java/res/layout/image_preview_view.xml b/java/res/layout/chooser_image_preview_view_internals.xml index d2f94690..2b93edf8 100644 --- a/java/res/layout/image_preview_view.xml +++ b/java/res/layout/chooser_image_preview_view_internals.xml @@ -25,8 +25,9 @@ <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_image_1_large" - android:layout_width="120dp" - android:layout_height="104dp" + android:transitionName="screenshot_preview_image" + android:layout_width="@dimen/chooser_preview_image_width" + android:layout_height="@dimen/chooser_preview_image_height" android:layout_alignParentTop="true" android:adjustViewBounds="true" android:gravity="center" @@ -35,8 +36,8 @@ <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_image_2_large" android:visibility="gone" - android:layout_width="120dp" - android:layout_height="104dp" + android:layout_width="@dimen/chooser_preview_image_width" + android:layout_height="@dimen/chooser_preview_image_height" android:layout_alignParentTop="true" android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" android:layout_marginLeft="10dp" @@ -47,7 +48,7 @@ <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_image_2_small" android:visibility="gone" - android:layout_width="120dp" + android:layout_width="@dimen/chooser_preview_image_width" android:layout_height="65dp" android:layout_alignParentTop="true" android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" @@ -59,7 +60,7 @@ <com.android.intentresolver.widget.RoundedRectImageView android:id="@androidprv:id/content_preview_image_3_small" android:visibility="gone" - android:layout_width="120dp" + android:layout_width="@dimen/chooser_preview_image_width" android:layout_height="65dp" android:layout_below="@androidprv:id/content_preview_image_2_small" android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml new file mode 100644 index 00000000..c18cc279 --- /dev/null +++ b/java/res/layout/image_preview_image_item.xml @@ -0,0 +1,24 @@ +<!-- + ~ 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. + --> + +<com.android.intentresolver.widget.RoundedRectImageView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/image" + android:layout_width="@dimen/chooser_preview_image_width" + android:layout_height="@dimen/chooser_preview_image_height" + android:layout_alignParentTop="true" + android:adjustViewBounds="false" + android:scaleType="centerCrop"/> diff --git a/java/res/layout/resolve_grid_item.xml b/java/res/layout/resolve_grid_item.xml index db6c7dd9..00ca9945 100644 --- a/java/res/layout/resolve_grid_item.xml +++ b/java/res/layout/resolve_grid_item.xml @@ -18,6 +18,7 @@ --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:id="@androidprv:id/item" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content" diff --git a/java/res/layout/scrollable_image_preview_view.xml b/java/res/layout/scrollable_image_preview_view.xml new file mode 100644 index 00000000..c6c310e6 --- /dev/null +++ b/java/res/layout/scrollable_image_preview_view.xml @@ -0,0 +1,26 @@ +<?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. + --> + +<com.android.intentresolver.widget.ScrollableImagePreviewView + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:paddingStart="@dimen/chooser_edge_margin_normal" + android:paddingEnd="@dimen/chooser_edge_margin_normal" + android:paddingBottom="@dimen/chooser_view_spacing" + android:background="?android:attr/colorBackground" /> diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index 93cb4637..87eec7fb 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -25,6 +25,8 @@ <dimen name="chooser_edge_margin_normal">24dp</dimen> <dimen name="chooser_preview_image_font_size">20sp</dimen> <dimen name="chooser_preview_image_border">1dp</dimen> + <dimen name="chooser_preview_image_width">120dp</dimen> + <dimen name="chooser_preview_image_height">104dp</dimen> <dimen name="chooser_preview_image_max_dimen">200dp</dimen> <dimen name="chooser_preview_width">-1px</dimen> <dimen name="chooser_header_scroll_elevation">4dp</dimen> diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index a536d3bc..24604ed3 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -101,4 +101,20 @@ <string name="miniresolver_use_personal_browser">Use personal browser</string> <!-- Button option. Open the link in the work browser. [CHAR LIMIT=NONE] --> <string name="miniresolver_use_work_browser">Use work browser</string> + + <!-- Tittle for a button. Launches client-provided content reselection action. --> + <string name="select_files">Select Files</string> + <!-- Tittle for a button. Launches client-provided content reselection action. --> + <string name="select_images">Select Images</string> + <!-- Tittle for a button. Launches client-provided content reselection action. --> + <string name="select_text">Select Text</string> + + <!-- Title for a button. Excludes a text from the shared content (a media and a text). --> + <string name="exclude_text">Exclude text</string> + <!-- Title for a button. Adds back a (previously excluded) text into the shared content. --> + <string name="include_text">Include text</string> + <!-- Title for a button. Excludes a web link from the shared content (a media and a text). --> + <string name="exclude_link">Exclude link</string> + <!-- Title for a button. Adds back a (previously excluded) web link into the shared content. --> + <string name="include_link">Include link</string> </resources> diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml index cbbf406d..ba6418a8 100644 --- a/java/res/values/styles.xml +++ b/java/res/values/styles.xml @@ -46,4 +46,12 @@ <item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item> <item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item> </style> + + <style name="TextAppearance.ChooserDefault" + parent="@android:style/TextAppearance.DeviceDefault" /> + + <style name="ReselectionAction" parent="TextAppearance.ChooserDefault"> + <item name="android:paddingTop">5dp</item> + <item name="android:paddingBottom">5dp</item> + </style> </resources> diff --git a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt new file mode 100644 index 00000000..5067c0ee --- /dev/null +++ b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.flags + +import android.util.SparseBooleanArray +import androidx.annotation.GuardedBy +import com.android.systemui.flags.BooleanFlag +import com.android.systemui.flags.FlagManager +import com.android.systemui.flags.ReleasedFlag +import com.android.systemui.flags.UnreleasedFlag +import javax.annotation.concurrent.ThreadSafe + +@ThreadSafe +internal class DebugFeatureFlagRepository( + private val flagManager: FlagManager, + private val deviceConfig: DeviceConfigProxy, +) : FeatureFlagRepository { + @GuardedBy("self") + private val cache = hashMapOf<String, Boolean>() + + override fun isEnabled(flag: UnreleasedFlag): Boolean = isFlagEnabled(flag) + + override fun isEnabled(flag: ReleasedFlag): Boolean = isFlagEnabled(flag) + + private fun isFlagEnabled(flag: BooleanFlag): Boolean { + synchronized(cache) { + cache[flag.name]?.let { return it } + } + val flagValue = readFlagValue(flag) + return synchronized(cache) { + // the first read saved in the cache wins + cache.getOrPut(flag.name) { flagValue } + } + } + + private fun readFlagValue(flag: BooleanFlag): Boolean { + val localOverride = runCatching { + flagManager.isEnabled(flag.name) + }.getOrDefault(null) + val remoteOverride = deviceConfig.isEnabled(flag) + + // Only check for teamfood if the default is false + // and there is no server override. + if (remoteOverride == null + && !flag.default + && localOverride == null + && !flag.isTeamfoodFlag + && flag.teamfood + ) { + return flagManager.isTeamfoodEnabled + } + return localOverride ?: remoteOverride ?: flag.default + } + + companion object { + /** keep in sync with [com.android.systemui.flags.Flags] */ + private const val TEAMFOOD_FLAG_NAME = "teamfood" + + private val BooleanFlag.isTeamfoodFlag: Boolean + get() = name == TEAMFOOD_FLAG_NAME + + private val FlagManager.isTeamfoodEnabled: Boolean + get() = runCatching { + isEnabled(TEAMFOOD_FLAG_NAME) ?: false + }.getOrDefault(false) + } +} diff --git a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt new file mode 100644 index 00000000..4ddb0447 --- /dev/null +++ b/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.flags + +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.android.systemui.flags.FlagManager + +class FeatureFlagRepositoryFactory { + fun create(context: Context): FeatureFlagRepository = + DebugFeatureFlagRepository( + FlagManager(context, Handler(Looper.getMainLooper())), + DeviceConfigProxy(), + ) +} diff --git a/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt new file mode 100644 index 00000000..6bf7579e --- /dev/null +++ b/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.flags + +import android.content.Context + +class FeatureFlagRepositoryFactory { + fun create(context: Context): FeatureFlagRepository = + ReleaseFeatureFlagRepository(DeviceConfigProxy()) +} diff --git a/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt b/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt new file mode 100644 index 00000000..f9fa2c6a --- /dev/null +++ b/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.flags + +import com.android.systemui.flags.ReleasedFlag +import com.android.systemui.flags.UnreleasedFlag +import javax.annotation.concurrent.ThreadSafe + +@ThreadSafe +internal class ReleaseFeatureFlagRepository( + private val deviceConfig: DeviceConfigProxy, +) : FeatureFlagRepository { + override fun isEnabled(flag: UnreleasedFlag): Boolean = flag.default + + override fun isEnabled(flag: ReleasedFlag): Boolean = + deviceConfig.isEnabled(flag) ?: flag.default +} diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java index 17dbb8f2..e3f1b233 100644 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java @@ -40,6 +40,7 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; /** * Skeletal {@link PagerAdapter} implementation of a work or personal profile page for @@ -61,22 +62,20 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { private Set<Integer> mLoadedPages; private final EmptyStateProvider mEmptyStateProvider; private final UserHandle mWorkProfileUserHandle; - private final QuietModeManager mQuietModeManager; + private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet. - AbstractMultiProfilePagerAdapter(Context context, int currentPage, + AbstractMultiProfilePagerAdapter( + Context context, + int currentPage, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier<Boolean> workProfileQuietModeChecker, UserHandle workProfileUserHandle) { mContext = Objects.requireNonNull(context); mCurrentPage = currentPage; mLoadedPages = new HashSet<>(); mWorkProfileUserHandle = workProfileUserHandle; mEmptyStateProvider = emptyStateProvider; - mQuietModeManager = quietModeManager; - } - - private boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { - return mQuietModeManager.isQuietModeEnabled(workProfileUserHandle); + mWorkProfileQuietModeChecker = workProfileQuietModeChecker; } void setOnProfileSelectedListener(OnProfileSelectedListener listener) { @@ -433,7 +432,7 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { int count = listAdapter.getUnfilteredCount(); return (count == 0 && listAdapter.getPlaceholderCount() == 0) || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && isQuietModeEnabled(mWorkProfileUserHandle)); + && mWorkProfileQuietModeChecker.get()); } protected static class ProfileDescriptor { @@ -573,29 +572,4 @@ public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { */ void onSwitchOnWorkSelected(); } - - /** - * Describes an injector to be used for cross profile functionality. Overridable for testing. - */ - public interface QuietModeManager { - /** - * Returns whether the given profile is in quiet mode or not. - */ - boolean isQuietModeEnabled(UserHandle workProfileUserHandle); - - /** - * Enables or disables quiet mode for a managed profile. - */ - void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle); - - /** - * Should be called when the work profile enabled broadcast received - */ - void markWorkProfileEnabledBroadcastReceived(); - - /** - * Returns true if enabling of work profile is in progress - */ - boolean isWaitingToEnableWorkProfile(); - } } diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java new file mode 100644 index 00000000..b4365b84 --- /dev/null +++ b/java/src/com/android/intentresolver/AnnotatedUserHandles.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import android.annotation.Nullable; +import android.app.Activity; +import android.app.ActivityManager; +import android.os.UserHandle; +import android.os.UserManager; + +/** + * Helper class to precompute the (immutable) designations of various user handles in the system + * that may contribute to the current Sharesheet session. + */ +public final class AnnotatedUserHandles { + /** The user id of the app that started the share activity. */ + public final int userIdOfCallingApp; + + /** + * The {@link UserHandle} that launched Sharesheet. + * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp} + * except possibly if the caller used {@link Activity#startActivityAsUser()} to launch + * Sharesheet as a different user than they themselves were running as. Verify and document. + */ + public final UserHandle userHandleSharesheetLaunchedAs; + + /** + * The {@link UserHandle} that owns the "personal tab" in a tabbed share UI (or the *only* 'tab' + * in a non-tabbed UI). + * + * This is never a work or clone user, but may either be the root user (0) or a "secondary" + * multi-user profile (i.e., one that's not root, work, nor clone). This is a "secondary" + * profile only when that user is the active "foreground" user. + * + * In the current implementation, we can assert that this is the root user (0) any time we + * display a tabbed UI (i.e., any time `workProfileUserHandle` is non-null), or any time that we + * have a clone profile. This note is only provided for informational purposes; clients should + * avoid making any reliances on that assumption. + */ + public final UserHandle personalProfileUserHandle; + + /** + * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary) + * one of the "managed" profiles associated with {@link personalProfileUserHandle}. + */ + @Nullable + public final UserHandle workProfileUserHandle; + + /** + * The {@link UserHandle} of the clone profile belonging to {@link personalProfileUserHandle}. + */ + @Nullable + public final UserHandle cloneProfileUserHandle; + + /** + * The "tab owner" user handle (i.e., either {@link personalProfileUserHandle} or + * {@link workProfileUserHandle}) that either matches or owns the profile of the + * {@link userHandleSharesheetLaunchedAs}. + * + * In the current implementation, we can assert that this is the same as + * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is + * the "personal" profile owning that clone profile (which we currently know must belong to + * user 0, but clients should avoid making any reliances on that assumption). + */ + public final UserHandle tabOwnerUserHandleForLaunch; + + public AnnotatedUserHandles(Activity forShareActivity) { + userIdOfCallingApp = forShareActivity.getLaunchedFromUid(); + if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) { + throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp); + } + + // TODO: integrate logic for `ResolverActivity.EXTRA_CALLING_USER`. + userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId()); + + personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser()); + + UserManager userManager = forShareActivity.getSystemService(UserManager.class); + workProfileUserHandle = getWorkProfileForUser(userManager, personalProfileUserHandle); + cloneProfileUserHandle = getCloneProfileForUser(userManager, personalProfileUserHandle); + + tabOwnerUserHandleForLaunch = (userHandleSharesheetLaunchedAs == workProfileUserHandle) + ? workProfileUserHandle : personalProfileUserHandle; + } + + @Nullable + private static UserHandle getWorkProfileForUser( + UserManager userManager, UserHandle profileOwnerUserHandle) { + return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()).stream() + .filter(info -> info.isManagedProfile()).findFirst() + .map(info -> info.getUserHandle()).orElse(null); + } + + @Nullable + private static UserHandle getCloneProfileForUser( + UserManager userManager, UserHandle profileOwnerUserHandle) { + return null; // Not yet supported in framework. + } +} diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java new file mode 100644 index 00000000..947155f3 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -0,0 +1,515 @@ +/* + * 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 android.annotation.Nullable; +import android.app.Activity; +import android.app.ActivityOptions; +import android.app.PendingIntent; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.service.chooser.ChooserAction; +import android.text.TextUtils; +import android.util.Log; +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.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; +import com.android.intentresolver.widget.ActionRow; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.function.Consumer; + +/** + * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application + * requirements of Sharesheet / {@link ChooserActivity}. + */ +public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { + /** Delegate interface to launch activities when the actions are selected. */ + public interface ActionActivityStarter { + /** + * Request an activity launch for the provided target. Implementations may choose to exit + * the current activity when the target is launched. + */ + void safelyStartActivityAsPersonalProfileUser(TargetInfo info); + + /** + * Request an activity launch for the provided target, optionally employing the specified + * shared element transition. Implementations may choose to exit the current activity when + * the target is launched. + */ + default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo info, View sharedElement, String sharedElementName) { + safelyStartActivityAsPersonalProfileUser(info); + } + } + + private static final String TAG = "ChooserActions"; + + private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + + private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; + private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; + + private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; + + private final Context mContext; + private final String mCopyButtonLabel; + private final Drawable mCopyButtonDrawable; + private final Runnable mOnCopyButtonClicked; + private final TargetInfo mEditSharingTarget; + private final Runnable mOnEditButtonClicked; + private final TargetInfo mNearbySharingTarget; + private final Runnable mOnNearbyButtonClicked; + private final ImmutableList<ChooserAction> mCustomActions; + private final Runnable mOnModifyShareClicked; + private final Consumer<Boolean> mExcludeSharedTextAction; + private final Consumer</* @Nullable */ Integer> mFinishCallback; + private final ChooserActivityLogger mLogger; + + /** + * @param context + * @param chooserRequest data about the invocation of the current Sharesheet session. + * @param featureFlagRepository feature flags that may control the eligibility of some actions. + * @param integratedDeviceComponents info about other components that are available on this + * device to implement the supported action types. + * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" + * setting is updated. The argument is whether the shared text is to be excluded. + * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image + * View in the Sharesheet UI, if any, or null. + * @param activityStarter a delegate to launch activities when actions are selected. + * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was + * completed). + */ + public ChooserActionFactory( + Context context, + ChooserRequestParameters chooserRequest, + FeatureFlagRepository featureFlagRepository, + ChooserIntegratedDeviceComponents integratedDeviceComponents, + ChooserActivityLogger logger, + Consumer<Boolean> onUpdateSharedTextIsExcluded, + Callable</* @Nullable */ View> firstVisibleImageQuery, + ActionActivityStarter activityStarter, + Consumer</* @Nullable */ Integer> finishCallback) { + this( + context, + context.getString(com.android.internal.R.string.copy), + context.getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), + makeOnCopyRunnable( + context, + chooserRequest.getTargetIntent(), + chooserRequest.getReferrerPackageName(), + finishCallback, + logger), + getEditSharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + makeOnEditRunnable( + getEditSharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + firstVisibleImageQuery, + activityStarter, + logger), + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + makeOnNearbyShareRunnable( + getNearbySharingTarget( + context, + chooserRequest.getTargetIntent(), + integratedDeviceComponents), + activityStarter, + finishCallback, + logger), + chooserRequest.getChooserActions(), + (featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) + ? createModifyShareRunnable( + chooserRequest.getModifyShareAction(), + finishCallback, + logger) + : null), + onUpdateSharedTextIsExcluded, + logger, + finishCallback); + } + + @VisibleForTesting + ChooserActionFactory( + Context context, + String copyButtonLabel, + Drawable copyButtonDrawable, + Runnable onCopyButtonClicked, + TargetInfo editSharingTarget, + Runnable onEditButtonClicked, + TargetInfo nearbySharingTarget, + Runnable onNearbyButtonClicked, + List<ChooserAction> customActions, + @Nullable Runnable onModifyShareClicked, + Consumer<Boolean> onUpdateSharedTextIsExcluded, + ChooserActivityLogger logger, + Consumer</* @Nullable */ Integer> finishCallback) { + mContext = context; + mCopyButtonLabel = copyButtonLabel; + mCopyButtonDrawable = copyButtonDrawable; + mOnCopyButtonClicked = onCopyButtonClicked; + mEditSharingTarget = editSharingTarget; + mOnEditButtonClicked = onEditButtonClicked; + mNearbySharingTarget = nearbySharingTarget; + mOnNearbyButtonClicked = onNearbyButtonClicked; + mCustomActions = ImmutableList.copyOf(customActions); + mOnModifyShareClicked = onModifyShareClicked; + mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; + mLogger = logger; + mFinishCallback = finishCallback; + } + + /** Create an action that copies the share content to the clipboard. */ + @Override + public ActionRow.Action createCopyButton() { + return new ActionRow.Action( + com.android.internal.R.id.chooser_copy_button, + mCopyButtonLabel, + mCopyButtonDrawable, + mOnCopyButtonClicked); + } + + /** Create an action that opens the share content in a system-default editor. */ + @Override + @Nullable + public ActionRow.Action createEditButton() { + if (mEditSharingTarget == null) { + return null; + } + + return new ActionRow.Action( + com.android.internal.R.id.chooser_edit_button, + mEditSharingTarget.getDisplayLabel(), + mEditSharingTarget.getDisplayIconHolder().getDisplayIcon(), + mOnEditButtonClicked); + } + + /** Create a "Share to Nearby" action. */ + @Override + @Nullable + public ActionRow.Action createNearbyButton() { + if (mNearbySharingTarget == null) { + return null; + } + + return new ActionRow.Action( + com.android.internal.R.id.chooser_nearby_button, + mNearbySharingTarget.getDisplayLabel(), + mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(), + mOnNearbyButtonClicked); + } + + /** Create custom actions */ + @Override + public List<ActionRow.Action> createCustomActions() { + List<ActionRow.Action> actions = new ArrayList<>(); + for (int i = 0; i < mCustomActions.size(); i++) { + ActionRow.Action actionRow = createCustomAction( + mContext, mCustomActions.get(i), mFinishCallback, i, mLogger); + if (actionRow != null) { + actions.add(actionRow); + } + } + return actions; + } + + /** + * Provides a share modification action, if any. + */ + @Override + @Nullable + public Runnable getModifyShareAction() { + return mOnModifyShareClicked; + } + + private static Runnable createModifyShareRunnable( + PendingIntent pendingIntent, + Consumer<Integer> finishCallback, + ChooserActivityLogger logger) { + if (pendingIntent == null) { + return null; + } + + return () -> { + try { + pendingIntent.send(); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Payload reselection action has been cancelled"); + } + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE); + finishCallback.accept(Activity.RESULT_OK); + }; + } + + /** + * <p> + * Creates an exclude-text action that can be called when the user changes shared text + * status in the Media + Text preview. + * </p> + * <p> + * <code>true</code> argument value indicates that the text should be excluded. + * </p> + */ + @Override + public Consumer<Boolean> getExcludeSharedTextAction() { + return mExcludeSharedTextAction; + } + + private static Runnable makeOnCopyRunnable( + Context context, + Intent targetIntent, + String referrerPackageName, + Consumer<Integer> finishCallback, + ChooserActivityLogger logger) { + return () -> { + if (targetIntent == null) { + finishCallback.accept(null); + return; + } + + final String action = targetIntent.getAction(); + + ClipData clipData = null; + if (Intent.ACTION_SEND.equals(action)) { + String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); + Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + + if (extraText != null) { + clipData = ClipData.newPlainText(null, extraText); + } else if (extraStream != null) { + clipData = ClipData.newUri(context.getContentResolver(), null, extraStream); + } else { + Log.w(TAG, "No data available to copy to clipboard"); + return; + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + final ArrayList<Uri> streams = targetIntent.getParcelableArrayListExtra( + Intent.EXTRA_STREAM); + clipData = ClipData.newUri(context.getContentResolver(), null, streams.get(0)); + for (int i = 1; i < streams.size(); i++) { + clipData.addItem( + context.getContentResolver(), + new ClipData.Item(streams.get(i))); + } + } else { + // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE + // so warn about unexpected action + Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard"); + return; + } + + ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( + Context.CLIPBOARD_SERVICE); + clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); + + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); + finishCallback.accept(Activity.RESULT_OK); + }; + } + + private static TargetInfo getEditSharingTarget( + Context context, + Intent originalIntent, + ChooserIntegratedDeviceComponents integratedComponents) { + final ComponentName editorComponent = integratedComponents.getEditSharingComponent(); + + final Intent resolveIntent = new Intent(originalIntent); + // Retain only URI permission grant flags if present. Other flags may prevent the scene + // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, + // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. + resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); + resolveIntent.setComponent(editorComponent); + resolveIntent.setAction(Intent.ACTION_EDIT); + String originalAction = originalIntent.getAction(); + if (Intent.ACTION_SEND.equals(originalAction)) { + if (resolveIntent.getData() == null) { + Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + String mimeType = context.getContentResolver().getType(uri); + resolveIntent.setDataAndType(uri, mimeType); + } + } + } else { + Log.e(TAG, originalAction + " is not supported."); + return null; + } + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available"); + return null; + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ri, + context.getString(com.android.internal.R.string.screenshot_edit), + "", + resolveIntent, + null); + dri.getDisplayIconHolder().setDisplayIcon( + context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); + return dri; + } + + private static Runnable makeOnEditRunnable( + TargetInfo editSharingTarget, + Callable</* @Nullable */ View> firstVisibleImageQuery, + ActionActivityStarter activityStarter, + ChooserActivityLogger logger) { + return () -> { + // Log share completion via edit. + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_EDIT); + + View firstImageView = null; + try { + firstImageView = firstVisibleImageQuery.call(); + } catch (Exception e) { /* ignore */ } + // Action bar is user-independent; always start as primary. + if (firstImageView == null) { + activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); + } else { + activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); + } + }; + } + + private static TargetInfo getNearbySharingTarget( + Context context, + Intent originalIntent, + ChooserIntegratedDeviceComponents integratedComponents) { + final ComponentName cn = integratedComponents.getNearbySharingComponent(); + if (cn == null) { + return null; + } + + final Intent resolveIntent = new Intent(originalIntent); + resolveIntent.setComponent(cn); + final ResolveInfo ri = context.getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified nearby sharing component (" + cn + + ") not available"); + return null; + } + + // Allow the nearby sharing component to provide a more appropriate icon and label + // for the chip. + CharSequence name = null; + Drawable icon = null; + final Bundle metaData = ri.activityInfo.metaData; + if (metaData != null) { + try { + final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn); + final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); + name = pkgRes.getString(nameResId); + final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); + icon = pkgRes.getDrawable(resId); + } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ } + } + if (TextUtils.isEmpty(name)) { + name = ri.loadLabel(context.getPackageManager()); + } + if (icon == null) { + icon = ri.loadIcon(context.getPackageManager()); + } + + final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, ri, name, "", resolveIntent, null); + dri.getDisplayIconHolder().setDisplayIcon(icon); + return dri; + } + + private static Runnable makeOnNearbyShareRunnable( + TargetInfo nearbyShareTarget, + ActionActivityStarter activityStarter, + Consumer<Integer> finishCallback, + ChooserActivityLogger logger) { + return () -> { + logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY); + // Action bar is user-independent; always start as primary. + activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget); + }; + } + + @Nullable + private static ActionRow.Action createCustomAction( + Context context, + ChooserAction action, + Consumer<Integer> finishCallback, + int position, + ChooserActivityLogger logger) { + Drawable icon = action.getIcon().loadDrawable(context); + if (icon == null && TextUtils.isEmpty(action.getLabel())) { + return null; + } + return new ActionRow.Action( + action.getLabel(), + icon, + () -> { + try { + action.getAction().send( + null, + 0, + null, + null, + null, + null, + ActivityOptions.makeCustomAnimation( + context, + R.anim.slide_in_right, + R.anim.slide_out_left) + .toBundle()); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); + } + logger.logCustomActionSelected(position); + finishCallback.accept(Activity.RESULT_OK); + } + ); + } +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index ceab62b2..ae5be26d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -36,44 +36,30 @@ import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.IntentSender; -import android.content.IntentSender.SendIntentException; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.content.res.Configuration; -import android.content.res.Resources; import android.database.Cursor; -import android.graphics.Bitmap; import android.graphics.Insets; -import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.Environment; -import android.os.Handler; -import android.os.Parcelable; -import android.os.PatternMatcher; -import android.os.ResultReceiver; import android.os.SystemClock; import android.os.UserHandle; import android.os.UserManager; import android.os.storage.StorageManager; import android.provider.DeviceConfig; -import android.provider.Settings; import android.service.chooser.ChooserTarget; -import android.text.TextUtils; import android.util.Log; -import android.util.Size; import android.util.Slog; import android.util.SparseArray; import android.view.View; @@ -97,6 +83,10 @@ import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyB import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; +import com.android.intentresolver.flags.Flags; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.grid.DirectShareViewHolder; import com.android.intentresolver.model.AbstractResolverComparator; @@ -104,16 +94,13 @@ import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.internal.util.FrameworkStatsLog; import java.io.File; -import java.io.IOException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.text.Collator; @@ -205,6 +192,8 @@ public class ChooserActivity extends ResolverActivity implements | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; + /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the * only assignment there, and expect it to be ready by the time we ever use it -- * someday if we move all the usage to a component with a narrower lifecycle (something that @@ -214,13 +203,15 @@ public class ChooserActivity extends ResolverActivity implements @Nullable private ChooserRequestParameters mChooserRequest; + private ChooserRefinementManager mRefinementManager; + + private FeatureFlagRepository mFeatureFlagRepository; + private ChooserContentPreviewUi mChooserContentPreviewUi; + private boolean mShouldDisplayLandscape; // statsd logger wrapper protected ChooserActivityLogger mChooserActivityLogger; - @Nullable - private RefinementResultReceiver mRefinementResultReceiver; - private long mChooserShownTime; protected boolean mIsSuccessfullySelected; @@ -240,9 +231,6 @@ public class ChooserActivity extends ResolverActivity implements private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); - @Nullable - private ChooserContentPreviewCoordinator mPreviewCoordinator; - private int mScrollStatus = SCROLL_STATUS_IDLE; @VisibleForTesting @@ -254,6 +242,8 @@ public class ChooserActivity extends ResolverActivity implements private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>(); + private boolean mExcludeSharedText = false; + public ChooserActivity() {} @Override @@ -263,9 +253,16 @@ public class ChooserActivity extends ResolverActivity implements getChooserActivityLogger().logSharesheetTriggered(); + mFeatureFlagRepository = createFeatureFlagRepository(); + mIntegratedDeviceComponents = getIntegratedDeviceComponents(); + try { mChooserRequest = new ChooserRequestParameters( - getIntent(), getReferrer(), getNearbySharingComponent()); + getIntent(), + getReferrerPackageName(), + getReferrer(), + mIntegratedDeviceComponents, + mFeatureFlagRepository); } catch (IllegalArgumentException e) { Log.e(TAG, "Caller provided invalid Chooser request parameters", e); finish(); @@ -273,6 +270,29 @@ public class ChooserActivity extends ResolverActivity implements return; } + mRefinementManager = new ChooserRefinementManager( + this, + mChooserRequest.getRefinementIntentSender(), + (validatedRefinedTarget) -> { + maybeRemoveSharedText(validatedRefinedTarget); + if (super.onTargetSelected(validatedRefinedTarget, false)) { + finish(); + } + }, + () -> { + mRefinementManager.destroy(); + finish(); + }); + + mChooserContentPreviewUi = new ChooserContentPreviewUi( + mChooserRequest.getTargetIntent(), + getContentResolver(), + this::isImageType, + createPreviewImageLoader(), + createChooserActionFactory(), + mEnterTransitionAnimationDelegate, + mFeatureFlagRepository); + setAdditionalTargets(mChooserRequest.getAdditionalTargets()); setSafeForwardingMode(true); @@ -291,11 +311,6 @@ public class ChooserActivity extends ResolverActivity implements mChooserRequest.getTargetIntentFilter()), mChooserRequest.getTargetIntentFilter()); - mPreviewCoordinator = new ChooserContentPreviewCoordinator( - mBackgroundThreadPoolExecutor, - this, - () -> mEnterTransitionAnimationDelegate.markImagePreviewReady(false)); - super.onCreate( savedInstanceState, mChooserRequest.getTargetIntent(), @@ -341,26 +356,35 @@ public class ChooserActivity extends ResolverActivity implements } getChooserActivityLogger().logShareStarted( - FrameworkStatsLog.SHARESHEET_STARTED, getReferrerPackageName(), mChooserRequest.getTargetType(), mChooserRequest.getCallerChooserTargets().size(), (mChooserRequest.getInitialIntents() == null) ? 0 : mChooserRequest.getInitialIntents().length, isWorkProfile(), - ChooserContentPreviewUi.findPreferredContentPreview( - getTargetIntent(), getContentResolver(), this::isImageType), - mChooserRequest.getTargetAction() + mChooserContentPreviewUi.getPreferredContentPreview(), + mChooserRequest.getTargetAction(), + mChooserRequest.getChooserActions().size(), + mChooserRequest.getModifyShareAction() != null ); mEnterTransitionAnimationDelegate.postponeTransition(); } + @VisibleForTesting + protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return ChooserIntegratedDeviceComponents.get(this, new SecureSettings()); + } + @Override protected int appliedThemeResId() { return R.style.Theme_DeviceDefault_Chooser; } + protected FeatureFlagRepository createFeatureFlagRepository() { + return new FeatureFlagRepositoryFactory().create(getApplicationContext()); + } + private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { UserHandle mainUserHandle = getPersonalProfileUserHandle(); @@ -489,7 +513,7 @@ public class ChooserActivity extends ResolverActivity implements /* context */ this, adapter, createEmptyStateProvider(/* workProfileUserHandle= */ null), - mQuietModeManager, + /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, mMaxTargetsPerRow); } @@ -518,7 +542,7 @@ public class ChooserActivity extends ResolverActivity implements personalAdapter, workAdapter, createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()), - mQuietModeManager, + () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, getWorkProfileUserHandle(), mMaxTargetsPerRow); @@ -539,8 +563,7 @@ public class ChooserActivity extends ResolverActivity implements || mChooserMultiProfilePagerAdapter .getCurrentRootAdapter().getSystemRowCount() != 0) { getChooserActivityLogger().logActionShareWithPreview( - ChooserContentPreviewUi.findPreferredContentPreview( - getTargetIntent(), getContentResolver(), this::isImageType)); + mChooserContentPreviewUi.getPreferredContentPreview()); } return postRebuildListInternal(rebuildCompleted); } @@ -591,51 +614,6 @@ public class ChooserActivity extends ResolverActivity implements updateProfileViewButton(); } - private void onCopyButtonClicked() { - Intent targetIntent = getTargetIntent(); - if (targetIntent == null) { - finish(); - } else { - final String action = targetIntent.getAction(); - - ClipData clipData = null; - if (Intent.ACTION_SEND.equals(action)) { - String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); - Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - - if (extraText != null) { - clipData = ClipData.newPlainText(null, extraText); - } else if (extraStream != null) { - clipData = ClipData.newUri(getContentResolver(), null, extraStream); - } else { - Log.w(TAG, "No data available to copy to clipboard"); - return; - } - } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { - final ArrayList<Uri> streams = targetIntent.getParcelableArrayListExtra( - Intent.EXTRA_STREAM); - clipData = ClipData.newUri(getContentResolver(), null, streams.get(0)); - for (int i = 1; i < streams.size(); i++) { - clipData.addItem(getContentResolver(), new ClipData.Item(streams.get(i))); - } - } else { - // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE - // so warn about unexpected action - Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard"); - return; - } - - ClipboardManager clipboardManager = (ClipboardManager) getSystemService( - Context.CLIPBOARD_SERVICE); - clipboardManager.setPrimaryClipAsPackage(clipData, getReferrerPackageName()); - - getChooserActivityLogger().logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); - - setResult(RESULT_OK); - finish(); - } - } - @Override protected void onResume() { super.onResume(); @@ -707,226 +685,19 @@ public class ChooserActivity extends ResolverActivity implements * @param parent reference to the parent container where the view should be attached to * @return content preview view */ - protected ViewGroup createContentPreviewView( - ViewGroup parent, - ChooserContentPreviewUi.ContentPreviewCoordinator previewCoordinator) { - Intent targetIntent = getTargetIntent(); - int previewType = ChooserContentPreviewUi.findPreferredContentPreview( - targetIntent, getContentResolver(), this::isImageType); - - ChooserContentPreviewUi.ActionFactory actionFactory = - new ChooserContentPreviewUi.ActionFactory() { - @Override - public ActionRow.Action createCopyButton() { - return ChooserActivity.this.createCopyAction(); - } - - @Nullable - @Override - public ActionRow.Action createEditButton() { - return ChooserActivity.this.createEditAction(targetIntent); - } - - @Nullable - @Override - public ActionRow.Action createNearbyButton() { - return ChooserActivity.this.createNearbyAction(targetIntent); - } - }; - - ViewGroup layout = ChooserContentPreviewUi.displayContentPreview( - previewType, - targetIntent, + protected ViewGroup createContentPreviewView(ViewGroup parent) { + ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( getResources(), getLayoutInflater(), - actionFactory, - R.layout.chooser_action_row, - parent, - previewCoordinator, - mEnterTransitionAnimationDelegate::markImagePreviewReady, - getContentResolver(), - this::isImageType); + parent); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); } - if (previewType != ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE) { - mEnterTransitionAnimationDelegate.markImagePreviewReady(false); - } return layout; } - @VisibleForTesting - protected ComponentName getNearbySharingComponent() { - String nearbyComponent = Settings.Secure.getString( - getContentResolver(), - Settings.Secure.NEARBY_SHARING_COMPONENT); - if (TextUtils.isEmpty(nearbyComponent)) { - nearbyComponent = getString(R.string.config_defaultNearbySharingComponent); - } - if (TextUtils.isEmpty(nearbyComponent)) { - return null; - } - return ComponentName.unflattenFromString(nearbyComponent); - } - - @VisibleForTesting - protected @Nullable ComponentName getEditSharingComponent() { - String editorPackage = getApplicationContext().getString(R.string.config_systemImageEditor); - if (editorPackage == null || TextUtils.isEmpty(editorPackage)) { - return null; - } - return ComponentName.unflattenFromString(editorPackage); - } - - @VisibleForTesting - protected TargetInfo getEditSharingTarget(Intent originalIntent) { - final ComponentName cn = getEditSharingComponent(); - - final Intent resolveIntent = new Intent(originalIntent); - // Retain only URI permission grant flags if present. Other flags may prevent the scene - // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, - // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. - resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); - resolveIntent.setComponent(cn); - resolveIntent.setAction(Intent.ACTION_EDIT); - String originalAction = originalIntent.getAction(); - if (Intent.ACTION_SEND.equals(originalAction)) { - if (resolveIntent.getData() == null) { - Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - String mimeType = getContentResolver().getType(uri); - resolveIntent.setDataAndType(uri, mimeType); - } - } - } else { - Log.e(TAG, originalAction + " is not supported."); - return null; - } - final ResolveInfo ri = getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified image edit component (" + cn - + ") not available"); - return null; - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ri, - getString(com.android.internal.R.string.screenshot_edit), - "", - resolveIntent, - null); - dri.getDisplayIconHolder().setDisplayIcon( - getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); - return dri; - } - - @VisibleForTesting - protected TargetInfo getNearbySharingTarget(Intent originalIntent) { - final ComponentName cn = getNearbySharingComponent(); - if (cn == null) return null; - - final Intent resolveIntent = new Intent(originalIntent); - resolveIntent.setComponent(cn); - final ResolveInfo ri = getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified nearby sharing component (" + cn - + ") not available"); - return null; - } - - // Allow the nearby sharing component to provide a more appropriate icon and label - // for the chip. - CharSequence name = null; - Drawable icon = null; - final Bundle metaData = ri.activityInfo.metaData; - if (metaData != null) { - try { - final Resources pkgRes = getPackageManager().getResourcesForActivity(cn); - final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); - name = pkgRes.getString(nameResId); - final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); - icon = pkgRes.getDrawable(resId); - } catch (Resources.NotFoundException ex) { - } catch (NameNotFoundException ex) { - } - } - if (TextUtils.isEmpty(name)) { - name = ri.loadLabel(getPackageManager()); - } - if (icon == null) { - icon = ri.loadIcon(getPackageManager()); - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, ri, name, "", resolveIntent, null); - dri.getDisplayIconHolder().setDisplayIcon(icon); - return dri; - } - - private ActionRow.Action createCopyAction() { - return new ActionRow.Action( - com.android.internal.R.id.chooser_copy_button, - getString(com.android.internal.R.string.copy), - getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), - this::onCopyButtonClicked); - } - - @Nullable - private ActionRow.Action createNearbyAction(Intent originalIntent) { - final TargetInfo ti = getNearbySharingTarget(originalIntent); - if (ti == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_nearby_button, - ti.getDisplayLabel(), - ti.getDisplayIconHolder().getDisplayIcon(), - () -> { - getChooserActivityLogger().logActionSelected( - ChooserActivityLogger.SELECTION_TYPE_NEARBY); - // Action bar is user-independent, always start as primary - safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); - finish(); - }); - } - - @Nullable - private ActionRow.Action createEditAction(Intent originalIntent) { - final TargetInfo ti = getEditSharingTarget(originalIntent); - if (ti == null) { - return null; - } - - return new ActionRow.Action( - com.android.internal.R.id.chooser_edit_button, - ti.getDisplayLabel(), - ti.getDisplayIconHolder().getDisplayIcon(), - () -> { - // Log share completion via edit - getChooserActivityLogger().logActionSelected( - ChooserActivityLogger.SELECTION_TYPE_EDIT); - View firstImgView = getFirstVisibleImgPreviewView(); - // Action bar is user-independent, always start as primary - if (firstImgView == null) { - safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); - finish(); - } else { - ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( - this, firstImgView, IMAGE_EDITOR_SHARED_ELEMENT); - safelyStartActivityAsUser( - ti, getPersonalProfileUserHandle(), options.toBundle()); - startFinishAnimation(); - } - } - ); - } - @Nullable private View getFirstVisibleImgPreviewView() { View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large); @@ -972,9 +743,9 @@ public class ChooserActivity extends ResolverActivity implements mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); } - if (mRefinementResultReceiver != null) { - mRefinementResultReceiver.destroy(); - mRefinementResultReceiver = null; + if (mRefinementManager != null) { // TODO: null-checked in case of early-destroy, or skip? + mRefinementManager.destroy(); + mRefinementManager = null; } mBackgroundThreadPoolExecutor.shutdownNow(); @@ -1098,34 +869,11 @@ public class ChooserActivity extends ResolverActivity implements @Override protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { - if (mChooserRequest.getRefinementIntentSender() != null) { - final Intent fillIn = new Intent(); - final List<Intent> sourceIntents = target.getAllSourceIntents(); - if (!sourceIntents.isEmpty()) { - fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0)); - if (sourceIntents.size() > 1) { - final Intent[] alts = new Intent[sourceIntents.size() - 1]; - for (int i = 1, N = sourceIntents.size(); i < N; i++) { - alts[i - 1] = sourceIntents.get(i); - } - fillIn.putExtra(Intent.EXTRA_ALTERNATE_INTENTS, alts); - } - if (mRefinementResultReceiver != null) { - mRefinementResultReceiver.destroy(); - } - mRefinementResultReceiver = new RefinementResultReceiver(this, target, null); - fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, - mRefinementResultReceiver); - try { - mChooserRequest.getRefinementIntentSender().sendIntent( - this, 0, fillIn, null, null); - return false; - } catch (SendIntentException e) { - Log.e(TAG, "Refinement IntentSender failed to send", e); - } - } + if (mRefinementManager.maybeHandleSelection(target)) { + return false; } updateModelAndChooserCounts(target); + maybeRemoveSharedText(target); return super.onTargetSelected(target, alwaysCheck); } @@ -1237,45 +985,6 @@ public class ChooserActivity extends ResolverActivity implements } } - private IntentFilter getTargetIntentFilter() { - return getTargetIntentFilter(getTargetIntent()); - } - - private IntentFilter getTargetIntentFilter(final Intent intent) { - try { - String dataString = intent.getDataString(); - if (intent.getType() == null) { - if (!TextUtils.isEmpty(dataString)) { - return new IntentFilter(intent.getAction(), dataString); - } - Log.e(TAG, "Failed to get target intent filter: intent data and type are null"); - return null; - } - IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType()); - List<Uri> contentUris = new ArrayList<>(); - if (Intent.ACTION_SEND.equals(intent.getAction())) { - Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - contentUris.add(uri); - } - } else { - List<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris != null) { - contentUris.addAll(uris); - } - } - for (Uri uri : contentUris) { - intentFilter.addDataScheme(uri.getScheme()); - intentFilter.addDataAuthority(uri.getAuthority(), null); - intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL); - } - return intentFilter; - } catch (Exception e) { - Log.e(TAG, "Failed to get target intent filter", e); - return null; - } - } - private void logDirectShareTargetReceived(UserHandle forUser) { ProfileRecord profileRecord = getProfileRecord(forUser); if (profileRecord == null) { @@ -1314,6 +1023,27 @@ public class ChooserActivity extends ResolverActivity implements mIsSuccessfullySelected = true; } + private void maybeRemoveSharedText(@androidx.annotation.NonNull TargetInfo targetInfo) { + Intent targetIntent = targetInfo.getTargetIntent(); + if (targetIntent == null) { + return; + } + Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent()); + // Our TargetInfo implementations add associated component to the intent, let's do the same + // for the sake of the comparison below. + if (targetIntent.getComponent() != null) { + originalTargetIntent.setComponent(targetIntent.getComponent()); + } + // Use filterEquals as a way to check that the primary intent is in use (and not an + // alternative one). For example, an app is sharing an image and a link with mime type + // "image/png" and provides an alternative intent to share only the link with mime type + // "text/uri". Should there be a target that accepts only the latter, the alternative intent + // will be used and we don't want to exclude the link from it. + if (mExcludeSharedText && originalTargetIntent.filterEquals(targetIntent)) { + targetIntent.removeExtra(Intent.EXTRA_TEXT); + } + } + private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) { // Send DS target impression info to AppPredictor, only when user chooses app share. if (targetInfo.isChooserTargetInfo()) { @@ -1369,46 +1099,6 @@ public class ChooserActivity extends ResolverActivity implements return (record == null) ? null : record.appPredictor; } - void onRefinementResult(TargetInfo selectedTarget, Intent matchingIntent) { - if (mRefinementResultReceiver != null) { - mRefinementResultReceiver.destroy(); - mRefinementResultReceiver = null; - } - if (selectedTarget == null) { - Log.e(TAG, "Refinement result intent did not match any known targets; canceling"); - } else if (!checkTargetSourceIntent(selectedTarget, matchingIntent)) { - Log.e(TAG, "onRefinementResult: Selected target " + selectedTarget - + " cannot match refined source intent " + matchingIntent); - } else { - TargetInfo clonedTarget = selectedTarget.cloneFilledIn(matchingIntent, 0); - if (super.onTargetSelected(clonedTarget, false)) { - updateModelAndChooserCounts(clonedTarget); - finish(); - return; - } - } - onRefinementCanceled(); - } - - void onRefinementCanceled() { - if (mRefinementResultReceiver != null) { - mRefinementResultReceiver.destroy(); - mRefinementResultReceiver = null; - } - finish(); - } - - boolean checkTargetSourceIntent(TargetInfo target, Intent matchingIntent) { - final List<Intent> targetIntents = target.getAllSourceIntents(); - for (int i = 0, N = targetIntents.size(); i < N; i++) { - final Intent targetIntent = targetIntents.get(i); - if (targetIntent.filterEquals(matchingIntent)) { - return true; - } - } - return false; - } - /** * Sort intents alphabetically based on display label. */ @@ -1433,14 +1123,19 @@ public class ChooserActivity extends ResolverActivity implements } public class ChooserListController extends ResolverListController { - public ChooserListController(Context context, + public ChooserListController( + Context context, PackageManager pm, Intent targetIntent, String referrerPackageName, int launchedFromUid, - UserHandle userId, AbstractResolverComparator resolverComparator) { - super(context, pm, targetIntent, referrerPackageName, launchedFromUid, userId, + super( + context, + pm, + targetIntent, + referrerPackageName, + launchedFromUid, resolverComparator); } @@ -1485,7 +1180,7 @@ public class ChooserActivity extends ResolverActivity implements @Override public View buildContentPreview(ViewGroup parent) { - return createContentPreviewView(parent, mPreviewCoordinator); + return createContentPreviewView(parent); } @Override @@ -1500,9 +1195,9 @@ public class ChooserActivity extends ResolverActivity implements .getActiveListAdapter() .targetInfoForPosition( selectedPosition, /* filtered= */ true); - // ItemViewHolder contents should always be "display resolve info" - // targets, but check just to make sure. - if (longPressedTargetInfo.isDisplayResolveInfo()) { + // Only a direct share target or an app target is expected + if (longPressedTargetInfo.isDisplayResolveInfo() + || longPressedTargetInfo.isSelectableTargetInfo()) { showTargetDetails(longPressedTargetInfo); } } @@ -1576,8 +1271,9 @@ public class ChooserActivity extends ResolverActivity implements maxTargetsPerRow); } + @Override @VisibleForTesting - protected ResolverListController createListController(UserHandle userHandle) { + protected ChooserListController createListController(UserHandle userHandle) { AppPredictor appPredictor = getAppPredictor(userHandle); AbstractResolverComparator resolverComparator; if (appPredictor != null) { @@ -1594,23 +1290,55 @@ public class ChooserActivity extends ResolverActivity implements mPm, getTargetIntent(), getReferrerPackageName(), - mLaunchedFromUid, - userHandle, + getAnnotatedUserHandles().userIdOfCallingApp, resolverComparator); } @VisibleForTesting - protected Bitmap loadThumbnail(Uri uri, Size size) { - if (uri == null || size == null) { - return null; + protected ImageLoader createPreviewImageLoader() { + final int cacheSize; + if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { + float chooserWidth = getResources().getDimension(R.dimen.chooser_width); + float imageWidth = getResources().getDimension(R.dimen.chooser_preview_image_width); + cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2); + } else { + cacheSize = 3; } + return new ImagePreviewImageLoader(this, getLifecycle(), cacheSize); + } - try { - return getContentResolver().loadThumbnail(uri, size, null); - } catch (IOException | NullPointerException | SecurityException ex) { - getChooserActivityLogger().logContentPreviewWarning(uri); - } - return null; + private ChooserActionFactory createChooserActionFactory() { + return new ChooserActionFactory( + this, + mChooserRequest, + mFeatureFlagRepository, + mIntegratedDeviceComponents, + getChooserActivityLogger(), + (isExcluded) -> mExcludeSharedText = isExcluded, + this::getFirstVisibleImgPreviewView, + new ChooserActionFactory.ActionActivityStarter() { + @Override + public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { + safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle()); + finish(); + } + + @Override + public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( + TargetInfo targetInfo, View sharedElement, String sharedElementName) { + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( + ChooserActivity.this, sharedElement, sharedElementName); + safelyStartActivityAsUser( + targetInfo, getPersonalProfileUserHandle(), options.toBundle()); + startFinishAnimation(); + } + }, + (status) -> { + if (status != null) { + setResult(status); + } + finish(); + }); } private void handleScroll(View view, int x, int y, int oldx, int oldy) { @@ -1845,21 +1573,20 @@ public class ChooserActivity extends ResolverActivity implements } @MainThread - private void onShortcutsLoaded( - UserHandle userHandle, ShortcutLoader.Result shortcutsResult) { + private void onShortcutsLoaded(UserHandle userHandle, ShortcutLoader.Result result) { if (DEBUG) { Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); } - mDirectShareShortcutInfoCache.putAll(shortcutsResult.directShareShortcutInfoCache); - mDirectShareAppTargetCache.putAll(shortcutsResult.directShareAppTargetCache); + mDirectShareShortcutInfoCache.putAll(result.getDirectShareShortcutInfoCache()); + mDirectShareAppTargetCache.putAll(result.getDirectShareAppTargetCache()); ChooserListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); if (adapter != null) { - for (ShortcutLoader.ShortcutResultInfo resultInfo : shortcutsResult.shortcutsByApp) { + for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) { adapter.addServiceResults( - resultInfo.appTarget, - resultInfo.shortcuts, - shortcutsResult.isFromAppPredictor + resultInfo.getAppTarget(), + resultInfo.getShortcuts(), + result.isFromAppPredictor() ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, mDirectShareShortcutInfoCache, @@ -1946,12 +1673,24 @@ public class ChooserActivity extends ResolverActivity implements private boolean shouldShowStickyContentPreviewNoOrientationCheck() { return shouldShowTabs() - && mMultiProfilePagerAdapter.getListAdapterForUserHandle( + && (mMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())).getCount() > 0 + || shouldShowContentPreviewWhenEmpty()) && shouldShowContentPreview(); } /** + * This method could be used to override the default behavior when we hide the preview area + * when the current tab doesn't have any items. + * + * @return true if we want to show the content preview area even if the tab for the current + * user is empty + */ + protected boolean shouldShowContentPreviewWhenEmpty() { + return false; + } + + /** * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { @@ -1964,10 +1703,10 @@ public class ChooserActivity extends ResolverActivity implements // We don't show it in landscape as otherwise there is no room for scrolling. // If the sticky content preview will be shown at some point with orientation change, // then always preload it to avoid subsequent resizing of the share sheet. - ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + ViewGroup contentPreviewContainer = + findViewById(com.android.internal.R.id.content_preview_container); if (contentPreviewContainer.getChildCount() == 0) { - ViewGroup contentPreviewView = - createContentPreviewView(contentPreviewContainer, mPreviewCoordinator); + ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); contentPreviewContainer.addView(contentPreviewView); } } @@ -2101,66 +1840,6 @@ public class ChooserActivity extends ResolverActivity implements } } - static class ChooserTargetRankingInfo { - public final List<AppTarget> scores; - public final UserHandle userHandle; - - ChooserTargetRankingInfo(List<AppTarget> chooserTargetScores, - UserHandle userHandle) { - this.scores = chooserTargetScores; - this.userHandle = userHandle; - } - } - - static class RefinementResultReceiver extends ResultReceiver { - private ChooserActivity mChooserActivity; - private TargetInfo mSelectedTarget; - - public RefinementResultReceiver(ChooserActivity host, TargetInfo target, - Handler handler) { - super(handler); - mChooserActivity = host; - mSelectedTarget = target; - } - - @Override - protected void onReceiveResult(int resultCode, Bundle resultData) { - if (mChooserActivity == null) { - Log.e(TAG, "Destroyed RefinementResultReceiver received a result"); - return; - } - if (resultData == null) { - Log.e(TAG, "RefinementResultReceiver received null resultData"); - return; - } - - switch (resultCode) { - case RESULT_CANCELED: - mChooserActivity.onRefinementCanceled(); - break; - case RESULT_OK: - Parcelable intentParcelable = resultData.getParcelable(Intent.EXTRA_INTENT); - if (intentParcelable instanceof Intent) { - mChooserActivity.onRefinementResult(mSelectedTarget, - (Intent) intentParcelable); - } else { - Log.e(TAG, "RefinementResultReceiver received RESULT_OK but no Intent" - + " in resultData with key Intent.EXTRA_INTENT"); - } - break; - default: - Log.w(TAG, "Unknown result code " + resultCode - + " sent to RefinementResultReceiver"); - break; - } - } - - public void destroy() { - mChooserActivity = null; - mSelectedTarget = null; - } - } - /** * Used in combination with the scene transition when launching the image editor */ diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java index 9109bf93..1f606f26 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -24,6 +24,7 @@ import android.provider.MediaStore; import android.util.HashedStringCache; import android.util.Log; +import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.InstanceId; import com.android.internal.logging.InstanceIdSequence; @@ -48,6 +49,8 @@ public class ChooserActivityLogger { public static final int SELECTION_TYPE_COPY = 4; public static final int SELECTION_TYPE_NEARBY = 5; public static final int SELECTION_TYPE_EDIT = 6; + public static final int SELECTION_TYPE_MODIFY_SHARE = 7; + public static final int SELECTION_TYPE_CUSTOM_ACTION = 8; /** * This shim is provided only for testing. In production, clients will only ever use a @@ -66,7 +69,9 @@ public class ChooserActivityLogger { int numAppProvidedAppTargets, boolean isWorkProfile, int previewType, - int intentType); + int intentType, + int numCustomActions, + boolean modifyShareActionProvided); /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */ void write( @@ -114,9 +119,16 @@ public class ChooserActivityLogger { } /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ - public void logShareStarted(int eventId, String packageName, String mimeType, - int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, - String intent) { + public void logShareStarted( + String packageName, + String mimeType, + int appProvidedDirect, + int appProvidedApp, + boolean isWorkprofile, + int previewType, + String intent, + int customActionCount, + boolean modifyShareActionProvided) { mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED, /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), /* package_name = 2 */ packageName, @@ -126,7 +138,24 @@ public class ChooserActivityLogger { /* num_app_provided_app_targets = 6 */ appProvidedApp, /* is_workprofile = 7 */ isWorkprofile, /* previewType = 8 */ typeFromPreviewInt(previewType), - /* intentType = 9 */ typeFromIntentString(intent)); + /* intentType = 9 */ typeFromIntentString(intent), + /* num_provided_custom_actions = 10 */ customActionCount, + /* modify_share_action_provided = 11 */ modifyShareActionProvided); + } + + /** + * Log that a custom action has been tapped by the user. + * + * @param positionPicked index of the custom action within the list of custom actions. + */ + public void logCustomActionSelected(int positionPicked) { + mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, + /* event_id = 1 */ + SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId(), + /* package_name = 2 */ null, + /* instance_id = 3 */ getInstanceId().getId(), + /* position_picked = 4 */ positionPicked, + /* is_pinned = 5 */ false); } /** @@ -328,7 +357,11 @@ public class ChooserActivityLogger { @UiEvent(doc = "User selected the nearby target.") SHARESHEET_NEARBY_TARGET_SELECTED(626), @UiEvent(doc = "User selected the edit target.") - SHARESHEET_EDIT_TARGET_SELECTED(669); + SHARESHEET_EDIT_TARGET_SELECTED(669), + @UiEvent(doc = "User selected the modify share target.") + SHARESHEET_MODIFY_SHARE_SELECTED(1316), + @UiEvent(doc = "User selected a custom action.") + SHARESHEET_CUSTOM_ACTION_SELECTED(1317); private final int mId; SharesheetTargetSelectedEvent(int id) { @@ -352,6 +385,10 @@ public class ChooserActivityLogger { return SHARESHEET_NEARBY_TARGET_SELECTED; case SELECTION_TYPE_EDIT: return SHARESHEET_EDIT_TARGET_SELECTED; + case SELECTION_TYPE_MODIFY_SHARE: + return SHARESHEET_MODIFY_SHARE_SELECTED; + case SELECTION_TYPE_CUSTOM_ACTION: + return SHARESHEET_CUSTOM_ACTION_SELECTED; default: return INVALID; } @@ -396,11 +433,11 @@ public class ChooserActivityLogger { */ private static int typeFromPreviewInt(int previewType) { switch(previewType) { - case ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE: + case ContentPreviewType.CONTENT_PREVIEW_IMAGE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE; - case ChooserContentPreviewUi.CONTENT_PREVIEW_FILE: + case ContentPreviewType.CONTENT_PREVIEW_FILE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE; - case ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT: + case ContentPreviewType.CONTENT_PREVIEW_TEXT: default: return FrameworkStatsLog .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN; @@ -463,7 +500,9 @@ public class ChooserActivityLogger { int numAppProvidedAppTargets, boolean isWorkProfile, int previewType, - int intentType) { + int intentType, + int numCustomActions, + boolean modifyShareActionProvided) { FrameworkStatsLog.write( frameworkEventId, /* event_id = 1 */ appEventId, @@ -474,7 +513,9 @@ public class ChooserActivityLogger { /* num_app_provided_app_targets */ numAppProvidedAppTargets, /* is_workprofile */ isWorkProfile, /* previewType = 8 */ previewType, - /* intentType = 9 */ intentType); + /* intentType = 9 */ intentType, + /* num_provided_custom_actions = 10 */ numCustomActions, + /* modify_share_action_provided = 11 */ modifyShareActionProvided); } @Override diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java deleted file mode 100644 index 0b8dbe35..00000000 --- a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (C) 2008 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Handler; -import android.util.Size; - -import androidx.annotation.MainThread; -import androidx.annotation.Nullable; - -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; - -import java.util.concurrent.ExecutorService; -import java.util.function.Consumer; - -/** - * Delegate to manage deferred resource loads for content preview assets, while - * implementing Chooser's application logic for determining timeout/success/failure conditions. - */ -public class ChooserContentPreviewCoordinator implements - ChooserContentPreviewUi.ContentPreviewCoordinator { - public ChooserContentPreviewCoordinator( - ExecutorService backgroundExecutor, - ChooserActivity chooserActivity, - Runnable onFailCallback) { - this.mBackgroundExecutor = MoreExecutors.listeningDecorator(backgroundExecutor); - this.mChooserActivity = chooserActivity; - this.mOnFailCallback = onFailCallback; - - this.mImageLoadTimeoutMillis = - chooserActivity.getResources().getInteger(R.integer.config_shortAnimTime); - } - - @Override - public void loadImage(final Uri imageUri, final Consumer<Bitmap> callback) { - final int size = mChooserActivity.getResources().getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen); - - // TODO: apparently this timeout is only used for not holding shared element transition - // animation for too long. If so, we already have a better place for it - // EnterTransitionAnimationDelegate. - mHandler.postDelayed(this::onWatchdogTimeout, mImageLoadTimeoutMillis); - - ListenableFuture<Bitmap> bitmapFuture = mBackgroundExecutor.submit( - () -> mChooserActivity.loadThumbnail(imageUri, new Size(size, size))); - - Futures.addCallback( - bitmapFuture, - new FutureCallback<Bitmap>() { - @Override - public void onSuccess(Bitmap loadedBitmap) { - try { - callback.accept(loadedBitmap); - onLoadCompleted(loadedBitmap); - } catch (Exception e) { /* unimportant */ } - } - - @Override - public void onFailure(Throwable t) { - callback.accept(null); - } - }, - mHandler::post); - } - - private final ChooserActivity mChooserActivity; - private final ListeningExecutorService mBackgroundExecutor; - private final Runnable mOnFailCallback; - private final int mImageLoadTimeoutMillis; - - // TODO: this uses a `Handler` because there doesn't seem to be a straightforward way to get a - // `ScheduledExecutorService` that posts to the UI thread unless we use Dagger. Eventually we'll - // use Dagger and can inject this as a `@UiThread ScheduledExecutorService`. - private final Handler mHandler = new Handler(); - - private boolean mAtLeastOneLoaded = false; - - @MainThread - private void onWatchdogTimeout() { - if (mChooserActivity.isFinishing()) { - return; - } - - // If at least one image loads within the timeout period, allow other loads to continue. - if (!mAtLeastOneLoaded) { - mOnFailCallback.run(); - } - } - - @MainThread - private void onLoadCompleted(@Nullable Bitmap loadedBitmap) { - if (mChooserActivity.isFinishing()) { - return; - } - - // TODO: the following logic can be described as "invoke the fail callback when the first - // image loading has failed". Historically, before we had switched from a single-threaded - // pool to a multi-threaded pool, we first loaded the transition element's image (the image - // preview is the only case when those callbacks matter) and aborting the animation on it's - // failure was reasonable. With the multi-thread pool, the first result may belong to any - // image and thus we can falsely abort the animation. - // Now, when we track the transition view state directly and after the timeout logic will - // be moved into ChooserActivity$EnterTransitionAnimationDelegate, we can just get rid of - // the fail callback and the following logic altogether. - mAtLeastOneLoaded |= loadedBitmap != null; - boolean wholeBatchFailed = !mAtLeastOneLoaded; - - if (wholeBatchFailed) { - mOnFailCallback.run(); - } - } -} diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java deleted file mode 100644 index ff88e5e1..00000000 --- a/java/src/com/android/intentresolver/ChooserContentPreviewUi.java +++ /dev/null @@ -1,566 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static java.lang.annotation.RetentionPolicy.SOURCE; - -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.annotation.IntDef; -import android.content.ClipData; -import android.content.ContentResolver; -import android.content.Intent; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.net.Uri; -import android.provider.DocumentsContract; -import android.provider.Downloads; -import android.provider.OpenableColumns; -import android.text.TextUtils; -import android.util.Log; -import android.util.PluralsMessageFormatter; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewStub; -import android.view.animation.DecelerateInterpolator; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.LayoutRes; -import androidx.annotation.Nullable; - -import com.android.intentresolver.widget.ActionRow; -import com.android.intentresolver.widget.ImagePreviewView; -import com.android.intentresolver.widget.RoundedRectImageView; -import com.android.internal.annotations.VisibleForTesting; - -import java.lang.annotation.Retention; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -/** - * Collection of helpers for building the content preview UI displayed in {@link ChooserActivity}. - * - * TODO: this "namespace" was pulled out of {@link ChooserActivity} as a bucket of static methods - * to show that they're one-shot procedures with no dependencies back to {@link ChooserActivity} - * state other than the delegates that are explicitly provided. There may be more appropriate - * abstractions (e.g., maybe this can be a "widget" added directly to the view hierarchy to show the - * appropriate preview), or it may at least be safe (and more convenient) to adopt a more "object - * oriented" design where the static specifiers are removed and some of the dependencies are cached - * as ivars when this "class" is initialized. - */ -public final class ChooserContentPreviewUi { - private static final int IMAGE_FADE_IN_MILLIS = 150; - - /** - * Delegate to handle background resource loads that are dependencies of content previews. - */ - public interface ContentPreviewCoordinator { - /** - * Request that an image be loaded in the background and set into a view. - * - * @param imageUri The {@link Uri} of the image to load. - * - * TODO: it looks like clients are probably capable of passing the view directly, but the - * deferred computation here is a closer match to the legacy model for now. - */ - void loadImage(Uri imageUri, Consumer<Bitmap> callback); - } - - /** - * Delegate to build the default system action buttons to display in the preview layout, if/when - * they're determined to be appropriate for the particular preview we display. - * TODO: clarify why action buttons are part of preview logic. - */ - public interface ActionFactory { - /** Create an action that copies the share content to the clipboard. */ - ActionRow.Action createCopyButton(); - - /** Create an action that opens the share content in a system-default editor. */ - @Nullable - ActionRow.Action createEditButton(); - - /** Create an "Share to Nearby" action. */ - @Nullable - ActionRow.Action createNearbyButton(); - } - - /** - * Testing shim to specify whether a given mime type is considered to be an "image." - * - * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests, - * then migrate {@link ChooserActivity#isImageType(String)} into this class. - */ - public interface ImageMimeTypeClassifier { - /** @return whether the specified {@code mimeType} is classified as an "image" type. */ - boolean isImageType(String mimeType); - } - - @Retention(SOURCE) - @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT}) - private @interface ContentPreviewType { - } - - // Starting at 1 since 0 is considered "undefined" for some of the database transformations - // of tron logs. - @VisibleForTesting - public static final int CONTENT_PREVIEW_IMAGE = 1; - @VisibleForTesting - public static final int CONTENT_PREVIEW_FILE = 2; - @VisibleForTesting - public static final int CONTENT_PREVIEW_TEXT = 3; - - private static final String TAG = "ChooserPreview"; - - private static final String PLURALS_COUNT = "count"; - private static final String PLURALS_FILE_NAME = "file_name"; - - /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */ - @ContentPreviewType - public static int findPreferredContentPreview( - Intent targetIntent, - ContentResolver resolver, - ImageMimeTypeClassifier imageClassifier) { - /* In {@link android.content.Intent#getType}, the app may specify a very general mime type - * that broadly covers all data being shared, such as {@literal *}/* when sending an image - * and text. We therefore should inspect each item for the preferred type, in order: IMAGE, - * FILE, TEXT. */ - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - return findPreferredContentPreview(uri, resolver, imageClassifier); - } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { - List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris == null || uris.isEmpty()) { - return CONTENT_PREVIEW_TEXT; - } - - for (Uri uri : uris) { - // Defaulting to file preview when there are mixed image/file types is - // preferable, as it shows the user the correct number of items being shared - int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier); - if (uriPreviewType == CONTENT_PREVIEW_FILE) { - return CONTENT_PREVIEW_FILE; - } - } - - return CONTENT_PREVIEW_IMAGE; - } - - return CONTENT_PREVIEW_TEXT; - } - - /** - * Display a content preview of the specified {@code previewType} to preview the content of the - * specified {@code intent}. - */ - public static ViewGroup displayContentPreview( - @ContentPreviewType int previewType, - Intent targetIntent, - Resources resources, - LayoutInflater layoutInflater, - ActionFactory actionFactory, - @LayoutRes int actionRowLayout, - ViewGroup parent, - ContentPreviewCoordinator previewCoord, - Consumer<Boolean> onTransitionTargetReady, - ContentResolver contentResolver, - ImageMimeTypeClassifier imageClassifier) { - ViewGroup layout = null; - - switch (previewType) { - case CONTENT_PREVIEW_TEXT: - layout = displayTextContentPreview( - targetIntent, - layoutInflater, - createTextPreviewActions(actionFactory), - parent, - previewCoord, - actionRowLayout); - break; - case CONTENT_PREVIEW_IMAGE: - layout = displayImageContentPreview( - targetIntent, - layoutInflater, - createImagePreviewActions(actionFactory), - parent, - previewCoord, - onTransitionTargetReady, - contentResolver, - imageClassifier, - actionRowLayout); - break; - case CONTENT_PREVIEW_FILE: - layout = displayFileContentPreview( - targetIntent, - resources, - layoutInflater, - createFilePreviewActions(actionFactory), - parent, - previewCoord, - contentResolver, - actionRowLayout); - break; - default: - Log.e(TAG, "Unexpected content preview type: " + previewType); - } - - return layout; - } - - private static Cursor queryResolver(ContentResolver resolver, Uri uri) { - return resolver.query(uri, null, null, null, null); - } - - @ContentPreviewType - private static int findPreferredContentPreview( - Uri uri, ContentResolver resolver, ImageMimeTypeClassifier imageClassifier) { - if (uri == null) { - return CONTENT_PREVIEW_TEXT; - } - - String mimeType = resolver.getType(uri); - return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; - } - - private static ViewGroup displayTextContentPreview( - Intent targetIntent, - LayoutInflater layoutInflater, - List<ActionRow.Action> actions, - ViewGroup parent, - ContentPreviewCoordinator previewCoord, - @LayoutRes int actionRowLayout) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_text, parent, false); - - final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); - if (actionRow != null) { - actionRow.setActions(actions); - } - - CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - if (sharingText == null) { - contentPreviewLayout - .findViewById(com.android.internal.R.id.content_preview_text_layout) - .setVisibility(View.GONE); - } else { - TextView textView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_text); - textView.setText(sharingText); - } - - String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); - if (TextUtils.isEmpty(previewTitle)) { - contentPreviewLayout - .findViewById(com.android.internal.R.id.content_preview_title_layout) - .setVisibility(View.GONE); - } else { - TextView previewTitleView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_title); - previewTitleView.setText(previewTitle); - - ClipData previewData = targetIntent.getClipData(); - Uri previewThumbnail = null; - if (previewData != null) { - if (previewData.getItemCount() > 0) { - ClipData.Item previewDataItem = previewData.getItemAt(0); - previewThumbnail = previewDataItem.getUri(); - } - } - - ImageView previewThumbnailView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail); - if (previewThumbnail == null) { - previewThumbnailView.setVisibility(View.GONE); - } else { - previewCoord.loadImage( - previewThumbnail, - (bitmap) -> updateViewWithImage( - contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail), - bitmap)); - } - } - - return contentPreviewLayout; - } - - private static List<ActionRow.Action> createTextPreviewActions(ActionFactory actionFactory) { - ArrayList<ActionRow.Action> actions = new ArrayList<>(2); - actions.add(actionFactory.createCopyButton()); - ActionRow.Action nearbyAction = actionFactory.createNearbyButton(); - if (nearbyAction != null) { - actions.add(nearbyAction); - } - return actions; - } - - private static ViewGroup displayImageContentPreview( - Intent targetIntent, - LayoutInflater layoutInflater, - List<ActionRow.Action> actions, - ViewGroup parent, - ContentPreviewCoordinator previewCoord, - Consumer<Boolean> onTransitionTargetReady, - ContentResolver contentResolver, - ImageMimeTypeClassifier imageClassifier, - @LayoutRes int actionRowLayout) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_image, parent, false); - ImagePreviewView imagePreview = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_image_area); - - final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); - if (actionRow != null) { - actionRow.setActions(actions); - } - - final ImagePreviewImageLoader imageLoader = new ImagePreviewImageLoader(previewCoord); - final ArrayList<Uri> imageUris = new ArrayList<>(); - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - // TODO: why don't we use image classifier in this case as well? - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - imageUris.add(uri); - } else { - List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - for (Uri uri : uris) { - if (imageClassifier.isImageType(contentResolver.getType(uri))) { - imageUris.add(uri); - } - } - } - - if (imageUris.size() == 0) { - Log.i(TAG, "Attempted to display image preview area with zero" - + " available images detected in EXTRA_STREAM list"); - imagePreview.setVisibility(View.GONE); - onTransitionTargetReady.accept(false); - return contentPreviewLayout; - } - - imagePreview.setSharedElementTransitionTarget( - ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME, - onTransitionTargetReady); - imagePreview.setImages(imageUris, imageLoader); - - return contentPreviewLayout; - } - - private static List<ActionRow.Action> createImagePreviewActions( - ActionFactory buttonFactory) { - ArrayList<ActionRow.Action> actions = new ArrayList<>(2); - //TODO: add copy action; - ActionRow.Action action = buttonFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } - action = buttonFactory.createEditButton(); - if (action != null) { - actions.add(action); - } - return actions; - } - - private static ViewGroup displayFileContentPreview( - Intent targetIntent, - Resources resources, - LayoutInflater layoutInflater, - List<ActionRow.Action> actions, - ViewGroup parent, - ContentPreviewCoordinator previewCoord, - ContentResolver contentResolver, - @LayoutRes int actionRowLayout) { - ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( - R.layout.chooser_grid_preview_file, parent, false); - - final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); - if (actionRow != null) { - actionRow.setActions(actions); - } - - String action = targetIntent.getAction(); - if (Intent.ACTION_SEND.equals(action)) { - Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); - loadFileUriIntoView(uri, contentPreviewLayout, previewCoord, contentResolver); - } else { - List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - int uriCount = uris.size(); - - if (uriCount == 0) { - contentPreviewLayout.setVisibility(View.GONE); - Log.i(TAG, - "Appears to be no uris available in EXTRA_STREAM, removing " - + "preview area"); - return contentPreviewLayout; - } else if (uriCount == 1) { - loadFileUriIntoView( - uris.get(0), contentPreviewLayout, previewCoord, contentResolver); - } else { - FileInfo fileInfo = extractFileInfo(uris.get(0), contentResolver); - int remUriCount = uriCount - 1; - Map<String, Object> arguments = new HashMap<>(); - arguments.put(PLURALS_COUNT, remUriCount); - arguments.put(PLURALS_FILE_NAME, fileInfo.name); - String fileName = - PluralsMessageFormatter.format(resources, arguments, R.string.file_count); - - TextView fileNameView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileName); - - View thumbnailView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail); - thumbnailView.setVisibility(View.GONE); - - ImageView fileIconView = contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_file_icon); - fileIconView.setVisibility(View.VISIBLE); - fileIconView.setImageResource(R.drawable.ic_file_copy); - } - } - - return contentPreviewLayout; - } - - private static List<ActionRow.Action> createFilePreviewActions(ActionFactory actionFactory) { - List<ActionRow.Action> actions = new ArrayList<>(1); - //TODO(b/120417119): - // add action buttonFactory.createCopyButton() - ActionRow.Action action = actionFactory.createNearbyButton(); - if (action != null) { - actions.add(action); - } - return actions; - } - - private static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) { - final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub); - if (stub != null) { - stub.setLayoutResource(actionRowLayout); - stub.inflate(); - } - return parent.findViewById(com.android.internal.R.id.chooser_action_row); - } - - private static void logContentPreviewWarning(Uri uri) { - // The ContentResolver already logs the exception. Log something more informative. - Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " - + "desired, consider using Intent#createChooser to launch the ChooserActivity, " - + "and set your Intent's clipData and flags in accordance with that method's " - + "documentation"); - } - - private static void loadFileUriIntoView( - final Uri uri, - final View parent, - final ContentPreviewCoordinator previewCoord, - final ContentResolver contentResolver) { - FileInfo fileInfo = extractFileInfo(uri, contentResolver); - - TextView fileNameView = parent.findViewById( - com.android.internal.R.id.content_preview_filename); - fileNameView.setText(fileInfo.name); - - if (fileInfo.hasThumbnail) { - previewCoord.loadImage( - uri, - (bitmap) -> updateViewWithImage( - parent.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail), - bitmap)); - } else { - View thumbnailView = parent.findViewById( - com.android.internal.R.id.content_preview_file_thumbnail); - thumbnailView.setVisibility(View.GONE); - - ImageView fileIconView = parent.findViewById( - com.android.internal.R.id.content_preview_file_icon); - fileIconView.setVisibility(View.VISIBLE); - fileIconView.setImageResource(R.drawable.chooser_file_generic); - } - } - - private static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) { - if (image == null) { - imageView.setVisibility(View.GONE); - return; - } - imageView.setVisibility(View.VISIBLE); - imageView.setAlpha(0.0f); - imageView.setImageBitmap(image); - - ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); - fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); - fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); - fadeAnim.start(); - } - - private static FileInfo extractFileInfo(Uri uri, ContentResolver resolver) { - String fileName = null; - boolean hasThumbnail = false; - - try (Cursor cursor = queryResolver(resolver, uri)) { - if (cursor != null && cursor.getCount() > 0) { - int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE); - int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS); - - cursor.moveToFirst(); - if (nameIndex != -1) { - fileName = cursor.getString(nameIndex); - } else if (titleIndex != -1) { - fileName = cursor.getString(titleIndex); - } - - if (flagsIndex != -1) { - hasThumbnail = (cursor.getInt(flagsIndex) - & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0; - } - } - } catch (SecurityException | NullPointerException e) { - logContentPreviewWarning(uri); - } - - if (TextUtils.isEmpty(fileName)) { - fileName = uri.getPath(); - int index = fileName.lastIndexOf('/'); - if (index != -1) { - fileName = fileName.substring(index + 1); - } - } - - return new FileInfo(fileName, hasThumbnail); - } - - private static class FileInfo { - public final String name; - public final boolean hasThumbnail; - - FileInfo(String name, boolean hasThumbnail) { - this.name = name; - this.hasThumbnail = hasThumbnail; - } - } - - private ChooserContentPreviewUi() {} -} diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java new file mode 100644 index 00000000..5fbf03a0 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java @@ -0,0 +1,83 @@ +/* + * 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 android.annotation.Nullable; +import android.content.ComponentName; +import android.content.Context; +import android.provider.Settings; +import android.text.TextUtils; + +import com.android.internal.annotations.VisibleForTesting; + +/** + * Helper to look up the components available on this device to handle assorted built-in actions + * like "Edit" that may be displayed for certain content/preview types. The components are queried + * when this record is instantiated, and are then immutable for a given instance. + * + * Because this describes the app's external execution environment, test methods may prefer to + * provide explicit values to override the default lookup logic. + */ +public class ChooserIntegratedDeviceComponents { + @Nullable + private final ComponentName mEditSharingComponent; + + @Nullable + private final ComponentName mNearbySharingComponent; + + /** Look up the integrated components available on this device. */ + public static ChooserIntegratedDeviceComponents get( + Context context, + SecureSettings secureSettings) { + return new ChooserIntegratedDeviceComponents( + getEditSharingComponent(context), + getNearbySharingComponent(context, secureSettings)); + } + + @VisibleForTesting + ChooserIntegratedDeviceComponents( + ComponentName editSharingComponent, ComponentName nearbySharingComponent) { + mEditSharingComponent = editSharingComponent; + mNearbySharingComponent = nearbySharingComponent; + } + + public ComponentName getEditSharingComponent() { + return mEditSharingComponent; + } + + public ComponentName getNearbySharingComponent() { + return mNearbySharingComponent; + } + + private static ComponentName getEditSharingComponent(Context context) { + String editorComponent = context.getApplicationContext().getString( + R.string.config_systemImageEditor); + return TextUtils.isEmpty(editorComponent) + ? null : ComponentName.unflattenFromString(editorComponent); + } + + private static ComponentName getNearbySharingComponent(Context context, + SecureSettings secureSettings) { + String nearbyComponent = secureSettings.getString( + context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT); + if (TextUtils.isEmpty(nearbyComponent)) { + nearbyComponent = context.getString(R.string.config_defaultNearbySharingComponent); + } + return TextUtils.isEmpty(nearbyComponent) + ? null : ComponentName.unflattenFromString(nearbyComponent); + } +} diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 699190f9..f0651360 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -49,7 +49,6 @@ import android.widget.TextView; import androidx.annotation.WorkerThread; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; @@ -264,7 +263,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); - holder.bindIcon(info); + holder.bindIcon(info, /*animate =*/ true); if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index 39d1fab0..3e2ea473 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -48,7 +48,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda Context context, ChooserGridAdapter adapter, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier<Boolean> workProfileQuietModeChecker, UserHandle workProfileUserHandle, int maxTargetsPerRow) { this( @@ -56,7 +56,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda new ChooserProfileAdapterBinder(maxTargetsPerRow), ImmutableList.of(adapter), emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, /* defaultProfile= */ 0, workProfileUserHandle, new BottomPaddingOverrideSupplier(context)); @@ -67,7 +67,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda ChooserGridAdapter personalAdapter, ChooserGridAdapter workAdapter, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier<Boolean> workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, int maxTargetsPerRow) { @@ -76,7 +76,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda new ChooserProfileAdapterBinder(maxTargetsPerRow), ImmutableList.of(personalAdapter, workAdapter), emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, new BottomPaddingOverrideSupplier(context)); @@ -87,7 +87,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda ChooserProfileAdapterBinder adapterBinder, ImmutableList<ChooserGridAdapter> gridAdapters, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier<Boolean> workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { @@ -97,7 +97,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda adapterBinder, gridAdapters, emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, () -> makeProfileView(context), diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java new file mode 100644 index 00000000..3ddc1c7c --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -0,0 +1,194 @@ +/* + * 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 android.annotation.Nullable; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.IntentSender; +import android.content.IntentSender.SendIntentException; +import android.os.Bundle; +import android.os.Handler; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.ResultReceiver; +import android.util.Log; + +import com.android.intentresolver.chooser.TargetInfo; + +import java.util.List; +import java.util.function.Consumer; + +/** + * Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement + * activity" that will be invoked when a target is selected, allowing the calling app to add + * additional extras and other refinements (subject to {@link Intent#filterEquals()}), e.g., to + * convert the format of the payload, or lazy-download some data that was deferred in the original + * call). + */ +public final class ChooserRefinementManager { + private static final String TAG = "ChooserRefinement"; + + @Nullable + private final IntentSender mRefinementIntentSender; + + private final Context mContext; + private final Consumer<TargetInfo> mOnSelectionRefined; + private final Runnable mOnRefinementCancelled; + + @Nullable + private RefinementResultReceiver mRefinementResultReceiver; + + public ChooserRefinementManager( + Context context, + @Nullable IntentSender refinementIntentSender, + Consumer<TargetInfo> onSelectionRefined, + Runnable onRefinementCancelled) { + mContext = context; + mRefinementIntentSender = refinementIntentSender; + mOnSelectionRefined = onSelectionRefined; + mOnRefinementCancelled = onRefinementCancelled; + } + + /** + * Delegate the user's {@code selectedTarget} to the refinement flow, if possible. + * @return true if the selection should wait for a now-started refinement flow, or false if it + * can proceed by the default (non-refinement) logic. + */ + public boolean maybeHandleSelection(TargetInfo selectedTarget) { + if (mRefinementIntentSender == null) { + return false; + } + if (selectedTarget.getAllSourceIntents().isEmpty()) { + return false; + } + + destroy(); // Terminate any prior sessions. + mRefinementResultReceiver = new RefinementResultReceiver( + refinedIntent -> { + destroy(); + TargetInfo refinedTarget = + selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent); + if (refinedTarget != null) { + mOnSelectionRefined.accept(refinedTarget); + } else { + Log.e(TAG, "Failed to apply refinement to any matching source intent"); + mOnRefinementCancelled.run(); + } + }, + mOnRefinementCancelled, + mContext.getMainThreadHandler()); + + Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget); + try { + mRefinementIntentSender.sendIntent(mContext, 0, refinementRequest, null, null); + return true; + } catch (SendIntentException e) { + Log.e(TAG, "Refinement IntentSender failed to send", e); + } + return false; + } + + /** Clean up any ongoing refinement session. */ + public void destroy() { + if (mRefinementResultReceiver != null) { + mRefinementResultReceiver.destroy(); + mRefinementResultReceiver = null; + } + } + + private static Intent makeRefinementRequest( + RefinementResultReceiver resultReceiver, TargetInfo originalTarget) { + final Intent fillIn = new Intent(); + final List<Intent> sourceIntents = originalTarget.getAllSourceIntents(); + fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0)); + final int sourceIntentCount = sourceIntents.size(); + if (sourceIntentCount > 1) { + fillIn.putExtra( + Intent.EXTRA_ALTERNATE_INTENTS, + sourceIntents + .subList(1, sourceIntentCount) + .toArray(new Intent[sourceIntentCount - 1])); + } + fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, resultReceiver.copyForSending()); + return fillIn; + } + + private static class RefinementResultReceiver extends ResultReceiver { + private final Consumer<Intent> mOnSelectionRefined; + private final Runnable mOnRefinementCancelled; + + private boolean mDestroyed; + + RefinementResultReceiver( + Consumer<Intent> onSelectionRefined, + Runnable onRefinementCancelled, + Handler handler) { + super(handler); + mOnSelectionRefined = onSelectionRefined; + mOnRefinementCancelled = onRefinementCancelled; + } + + public void destroy() { + mDestroyed = true; + } + + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + if (mDestroyed) { + Log.e(TAG, "Destroyed RefinementResultReceiver received a result"); + return; + } + if (resultData == null) { + Log.e(TAG, "RefinementResultReceiver received null resultData"); + // TODO: treat as cancellation? + return; + } + + switch (resultCode) { + case Activity.RESULT_CANCELED: + mOnRefinementCancelled.run(); + break; + case Activity.RESULT_OK: + Parcelable intentParcelable = resultData.getParcelable(Intent.EXTRA_INTENT); + if (intentParcelable instanceof Intent) { + mOnSelectionRefined.accept((Intent) intentParcelable); + } else { + Log.e(TAG, "No valid Intent.EXTRA_INTENT in 'OK' refinement result data"); + } + break; + default: + Log.w(TAG, "Received unknown refinement result " + resultCode); + break; + } + } + + /** + * Apps can't load this class directly, so we need a regular ResultReceiver copy for + * sending. Obtain this by parceling and unparceling (one weird trick). + */ + ResultReceiver copyForSending() { + Parcel parcel = Parcel.obtain(); + writeToParcel(parcel, 0); + parcel.setDataPosition(0); + ResultReceiver receiverForSending = ResultReceiver.CREATOR.createFromParcel(parcel); + parcel.recycle(); + return receiverForSending; + } + } +} diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 81481bf1..3d99e475 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -18,6 +18,7 @@ package com.android.intentresolver; import android.annotation.NonNull; import android.annotation.Nullable; +import android.app.PendingIntent; import android.content.ComponentName; import android.content.Intent; import android.content.IntentFilter; @@ -26,11 +27,15 @@ import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import android.os.PatternMatcher; +import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.Log; import android.util.Pair; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; + import com.google.common.collect.ImmutableList; import java.net.URISyntaxException; @@ -66,10 +71,14 @@ public class ChooserRequestParameters { Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; private final Intent mTarget; + private final ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; + private final String mReferrerPackageName; private final Pair<CharSequence, Integer> mTitleSpec; private final Intent mReferrerFillInIntent; private final ImmutableList<ComponentName> mFilteredComponentNames; private final ImmutableList<ChooserTarget> mCallerChooserTargets; + private final @NonNull ImmutableList<ChooserAction> mChooserActions; + private final PendingIntent mModifyShareAction; private final boolean mRetainInOnStop; @Nullable @@ -95,12 +104,18 @@ public class ChooserRequestParameters { public ChooserRequestParameters( final Intent clientIntent, + String referrerPackageName, final Uri referrer, - @Nullable final ComponentName nearbySharingComponent) { + ChooserIntegratedDeviceComponents integratedDeviceComponents, + FeatureFlagRepository featureFlags) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); + mIntegratedDeviceComponents = integratedDeviceComponents; + + mReferrerPackageName = referrerPackageName; + mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( clientIntent, Intent.EXTRA_ALTERNATE_INTENTS); @@ -120,7 +135,8 @@ public class ChooserRequestParameters { mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); - mFilteredComponentNames = getFilteredComponentNames(clientIntent, nearbySharingComponent); + mFilteredComponentNames = getFilteredComponentNames( + clientIntent, mIntegratedDeviceComponents.getNearbySharingComponent()); mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); @@ -130,6 +146,13 @@ public class ChooserRequestParameters { mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT); mTargetIntentFilter = getTargetIntentFilter(mTarget); + + mChooserActions = featureFlags.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) + ? getChooserActions(clientIntent) + : ImmutableList.of(); + mModifyShareAction = featureFlags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) + ? getModifyShareAction(clientIntent) + : null; } public Intent getTargetIntent() { @@ -150,6 +173,10 @@ public class ChooserRequestParameters { return getTargetIntent().getType(); } + public String getReferrerPackageName() { + return mReferrerPackageName; + } + @Nullable public CharSequence getTitle() { return mTitleSpec.first; @@ -171,8 +198,18 @@ public class ChooserRequestParameters { return mCallerChooserTargets; } + @NonNull + public ImmutableList<ChooserAction> getChooserActions() { + return mChooserActions; + } + + @Nullable + public PendingIntent getModifyShareAction() { + return mModifyShareAction; + } + /** - * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. + * Whether the {@link ChooserActivity#EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. */ public boolean shouldRetainInOnStop() { return mRetainInOnStop; @@ -221,6 +258,10 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } + public ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return mIntegratedDeviceComponents; + } + private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } @@ -300,6 +341,32 @@ public class ChooserRequestParameters { .collect(toImmutableList()); } + @NonNull + private static ImmutableList<ChooserAction> getChooserActions(Intent intent) { + return streamParcelableArrayExtra( + intent, + Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, + ChooserAction.class, + true, + true) + .collect(toImmutableList()); + } + + @Nullable + private static PendingIntent getModifyShareAction(Intent intent) { + try { + return intent.getParcelableExtra( + Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, + PendingIntent.class); + } catch (Throwable t) { + Log.w( + TAG, + "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION argument", + t); + return null; + } + } + private static <T> Collector<T, ?, ImmutableList<T>> toImmutableList() { return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); } diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt index a0bf61b6..b1178aa5 100644 --- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -15,23 +15,31 @@ */ package com.android.intentresolver -import android.app.Activity import android.app.SharedElementCallback import android.view.View -import com.android.intentresolver.widget.ResolverDrawerLayout +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback +import com.android.internal.annotations.VisibleForTesting +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.util.function.Supplier /** * A helper class to track app's readiness for the scene transition animation. * The app is ready when both the image is laid out and the drawer offset is calculated. */ -internal class EnterTransitionAnimationDelegate( - private val activity: Activity, - private val resolverDrawerLayoutSupplier: Supplier<ResolverDrawerLayout?> -) : View.OnLayoutChangeListener { - private var removeSharedElements = false +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) +class EnterTransitionAnimationDelegate( + private val activity: ComponentActivity, + private val transitionTargetSupplier: Supplier<View?>, +) : View.OnLayoutChangeListener, TransitionElementStatusCallback { + + private val transitionElements = HashSet<String>() private var previewReady = false private var offsetCalculated = false + private var timeoutJob: Job? = null init { activity.setEnterSharedElementCallback( @@ -46,12 +54,27 @@ internal class EnterTransitionAnimationDelegate( }) } - fun postponeTransition() = activity.postponeEnterTransition() - - fun markImagePreviewReady(runTransitionAnimation: Boolean) { - if (!runTransitionAnimation) { - removeSharedElements = true + fun postponeTransition() { + activity.postponeEnterTransition() + timeoutJob = activity.lifecycleScope.launch { + delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong()) + onTimeout() } + } + + private fun onTimeout() { + // We only mark the preview readiness and not the offset readiness + // (see [#markOffsetCalculated()]) as this is what legacy logic, effectively, did. We might + // want to review that aspect separately. + onAllTransitionElementsReady() + } + + override fun onTransitionElementReady(name: String) { + transitionElements.add(name) + } + + override fun onAllTransitionElementsReady() { + timeoutJob?.cancel() if (!previewReady) { previewReady = true maybeStartListenForLayout() @@ -69,15 +92,12 @@ internal class EnterTransitionAnimationDelegate( names: MutableList<String>, sharedElements: MutableMap<String, View> ) { - if (removeSharedElements) { - names.remove(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME) - sharedElements.remove(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME) - } - removeSharedElements = false + names.removeAll { !transitionElements.contains(it) } + sharedElements.entries.removeAll { !transitionElements.contains(it.key) } } private fun maybeStartListenForLayout() { - val drawer = resolverDrawerLayoutSupplier.get() + val drawer = transitionTargetSupplier.get() if (previewReady && offsetCalculated && drawer != null) { if (drawer.isInLayout) { startPostponedEnterTransition() @@ -98,7 +118,7 @@ internal class EnterTransitionAnimationDelegate( } private fun startPostponedEnterTransition() { - if (!removeSharedElements && activity.isActivityTransitionRunning) { + if (transitionElements.isNotEmpty() && activity.isActivityTransitionRunning) { // Disable the window animations as it interferes with the transition animation. activity.window.setWindowAnimations(0) } diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java index 9bbdf7c7..7613f35f 100644 --- a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java @@ -81,7 +81,7 @@ class GenericMultiProfilePagerAdapter< AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder, ImmutableList<SinglePageAdapterT> adapters, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier<Boolean> workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, Supplier<ViewGroup> pageViewInflater, @@ -90,7 +90,7 @@ class GenericMultiProfilePagerAdapter< context, /* currentPage= */ defaultProfile, emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, workProfileUserHandle); mListAdapterExtractor = listAdapterExtractor; diff --git a/java/src/com/android/intentresolver/ImageLoader.kt b/java/src/com/android/intentresolver/ImageLoader.kt new file mode 100644 index 00000000..0ed8b122 --- /dev/null +++ b/java/src/com/android/intentresolver/ImageLoader.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.graphics.Bitmap +import android.net.Uri +import java.util.function.Consumer + +interface ImageLoader : suspend (Uri) -> Bitmap? { + fun loadImage(uri: Uri, callback: Consumer<Bitmap?>) + fun prePopulate(uris: List<Uri>) +} diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt index e68eb66a..7b6651a2 100644 --- a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt @@ -16,23 +16,72 @@ package com.android.intentresolver +import android.content.Context import android.graphics.Bitmap import android.net.Uri -import kotlinx.coroutines.suspendCancellableCoroutine - -// TODO: convert ChooserContentPreviewCoordinator to Kotlin and merge this class into it. -internal class ImagePreviewImageLoader( - private val previewCoordinator: ChooserContentPreviewUi.ContentPreviewCoordinator -) : suspend (Uri) -> Bitmap? { - - override suspend fun invoke(uri: Uri): Bitmap? = - suspendCancellableCoroutine { continuation -> - val callback = java.util.function.Consumer<Bitmap?> { bitmap -> - try { - continuation.resumeWith(Result.success(bitmap)) - } catch (ignored: Exception) { - } +import android.util.Size +import androidx.annotation.GuardedBy +import androidx.annotation.VisibleForTesting +import androidx.collection.LruCache +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.function.Consumer + +@VisibleForTesting +class ImagePreviewImageLoader @JvmOverloads constructor( + private val context: Context, + private val lifecycle: Lifecycle, + cacheSize: Int, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : ImageLoader { + + private val thumbnailSize: Size = + context.resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen).let { + Size(it, it) + } + + @GuardedBy("self") + private val cache = LruCache<Uri, CompletableDeferred<Bitmap?>>(cacheSize) + + override suspend fun invoke(uri: Uri): Bitmap? = loadImageAsync(uri) + + override fun loadImage(uri: Uri, callback: Consumer<Bitmap?>) { + lifecycle.coroutineScope.launch { + val image = loadImageAsync(uri) + if (isActive) { + callback.accept(image) } - previewCoordinator.loadImage(uri, callback) } + } + + override fun prePopulate(uris: List<Uri>) { + uris.asSequence().take(cache.maxSize()).forEach { uri -> + lifecycle.coroutineScope.launch { + loadImageAsync(uri) + } + } + } + + private suspend fun loadImageAsync(uri: Uri): Bitmap? { + return synchronized(cache) { + cache.get(uri) ?: CompletableDeferred<Bitmap?>().also { result -> + cache.put(uri, result) + lifecycle.coroutineScope.launch(dispatcher) { + result.loadBitmap(uri) + } + } + }.await() + } + + private fun CompletableDeferred<Bitmap?>.loadBitmap(uri: Uri) { + val bitmap = runCatching { + context.contentResolver.loadThumbnail(uri, thumbnailSize, null) + }.getOrNull() + complete(bitmap) + } } diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java index 5bf994d6..c1373f4b 100644 --- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java @@ -101,9 +101,9 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { if (mWorkProfileUserHandle == null) { return false; } - List<ResolverActivity.ResolvedComponentInfo> resolversForIntent = + List<ResolvedComponentInfo> resolversForIntent = adapter.getResolversForUser(UserHandle.of(mMyUserIdProvider.getMyUserId())); - for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) { + for (ResolvedComponentInfo info : resolversForIntent) { ResolveInfo resolveInfo = info.getResolveInfoAt(0); if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { return true; @@ -151,4 +151,4 @@ public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { .write(); } } -}
\ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/ResolvedComponentInfo.java b/java/src/com/android/intentresolver/ResolvedComponentInfo.java new file mode 100644 index 00000000..ecb72cbf --- /dev/null +++ b/java/src/com/android/intentresolver/ResolvedComponentInfo.java @@ -0,0 +1,105 @@ +/* + * 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 android.content.ComponentName; +import android.content.Intent; +import android.content.pm.ResolveInfo; + +import java.util.ArrayList; +import java.util.List; + +/** + * Record type to store all resolutions that are deduped to a single target component, along with + * other metadata about the component (which applies to all of the resolutions in the record). + * This record is assembled when we're first processing resolutions, and then later it's used to + * derive the {@link TargetInfo} record(s) that specify how the resolutions will be presented as + * targets in the UI. + */ +public final class ResolvedComponentInfo { + public final ComponentName name; + private final List<Intent> mIntents = new ArrayList<>(); + private final List<ResolveInfo> mResolveInfos = new ArrayList<>(); + private boolean mPinned; + + /** + * @param name the name of the component that owns all the resolutions added to this record. + * @param intent an initial {@link Intent} to add to this record + * @param info the {@link ResolveInfo} associated with the given {@code intent}. + */ + public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) { + this.name = name; + add(intent, info); + } + + /** + * Add an {@link Intent} and associated {@link ResolveInfo} as resolutions for this component. + */ + public void add(Intent intent, ResolveInfo info) { + mIntents.add(intent); + mResolveInfos.add(info); + } + + /** @return the number of {@link Intent}/{@link ResolveInfo} pairs added to this record. */ + public int getCount() { + return mIntents.size(); + } + + /** @return the {@link Intent} at the specified {@code index}, if any, or else null. */ + public Intent getIntentAt(int index) { + return (index >= 0) ? mIntents.get(index) : null; + } + + /** @return the {@link ResolveInfo} at the specified {@code index}, if any, or else null. */ + public ResolveInfo getResolveInfoAt(int index) { + return (index >= 0) ? mResolveInfos.get(index) : null; + } + + /** + * @return the index of the provided {@link Intent} among those that have been added to this + * {@link ResolvedComponentInfo}, or -1 if it has't been added. + */ + public int findIntent(Intent intent) { + return mIntents.indexOf(intent); + } + + /** + * @return the index of the provided {@link ResolveInfo} among those that have been added to + * this {@link ResolvedComponentInfo}, or -1 if it has't been added. + */ + public int findResolveInfo(ResolveInfo info) { + return mResolveInfos.indexOf(info); + } + + /** + * @return whether this component was pinned by a call to {@link #setPinned()}. + * TODO: consolidate sources of pinning data and/or document how this differs from other places + * we make a "pinning" determination. + */ + public boolean isPinned() { + return mPinned; + } + + /** + * Set whether this component will be considered pinned in future calls to {@link #isPinned()}. + * TODO: consolidate sources of pinning data and/or document how this differs from other places + * we make a "pinning" determination. + */ + public void setPinned(boolean pinned) { + mPinned = pinned; + } +} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 5573e18a..d224299e 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -44,7 +44,6 @@ import android.app.VoiceInteractor.PickOptionRequest.Option; import android.app.VoiceInteractor.Prompt; import android.app.admin.DevicePolicyEventLogger; import android.app.admin.DevicePolicyManager; -import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -61,7 +60,6 @@ import android.content.res.TypedArray; import android.graphics.Insets; import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.PatternMatcher; @@ -105,7 +103,6 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStatePro import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -163,7 +160,6 @@ public class ResolverActivity extends FragmentActivity implements protected boolean mSupportsAlwaysUseOption; protected ResolverDrawerLayout mResolverDrawerLayout; protected PackageManager mPm; - protected int mLaunchedFromUid; private static final String TAG = "ResolverActivity"; private static final boolean DEBUG = false; @@ -192,7 +188,7 @@ public class ResolverActivity extends FragmentActivity implements @VisibleForTesting protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter; - protected QuietModeManager mQuietModeManager; + protected WorkProfileAvailabilityManager mWorkProfileAvailability; // Intent extra for connected audio devices public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; @@ -202,7 +198,7 @@ public class ResolverActivity extends FragmentActivity implements * <p>Can only be used if there is a work profile. * <p>Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}. */ - static final String EXTRA_SELECTED_PROFILE = + protected static final String EXTRA_SELECTED_PROFILE = "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE"; /** @@ -217,15 +213,20 @@ public class ResolverActivity extends FragmentActivity implements static final String EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; - static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL; - static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK; + protected static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL; + protected static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK; - private BroadcastReceiver mWorkProfileStateReceiver; private UserHandle mHeaderCreatorUser; - private Supplier<UserHandle> mLazyWorkProfileUserHandle = () -> { - final UserHandle result = fetchWorkProfileUserProfile(); - mLazyWorkProfileUserHandle = () -> result; + // User handle annotations are lazy-initialized to ensure that they're computed exactly once + // (even though they can't be computed prior to activity creation). + // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or + // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a + // new component whose lifecycle is limited to the "created" Activity (so that we can just hold + // the annotations as a `final` ivar, which is a better way to show immutability). + private Supplier<AnnotatedUserHandles> mLazyAnnotatedUserHandles = () -> { + final AnnotatedUserHandles result = new AnnotatedUserHandles(this); + mLazyAnnotatedUserHandles = () -> result; return result; }; @@ -234,22 +235,6 @@ public class ResolverActivity extends FragmentActivity implements protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - private LatencyTracker getLatencyTracker() { - return LatencyTracker.getInstance(this); - } - - /** - * Get the string resource to be used as a label for the link to the resolver activity for an - * action. - * - * @param action The action to resolve - * - * @return The string resource to be used as a label - */ - public static @StringRes int getLabelRes(String action) { - return ActionTitle.forAction(action).labelRes; - } - private enum ActionTitle { VIEW(Intent.ACTION_VIEW, com.android.internal.R.string.whichViewApplication, @@ -333,27 +318,6 @@ public class ResolverActivity extends FragmentActivity implements }; } - private Intent makeMyIntent() { - Intent intent = new Intent(getIntent()); - intent.setComponent(null); - // The resolver activity is set to be hidden from recent tasks. - // we don't want this attribute to be propagated to the next activity - // being launched. Note that if the original Intent also had this - // flag set, we are now losing it. That should be a very rare case - // and we can live with this. - intent.setFlags(intent.getFlags()&~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); - return intent; - } - - /** - * Call {@link Activity#onCreate} without initializing anything further. This should - * only be used when the activity is about to be immediately finished to avoid wasting - * initializing steps and leaking resources. - */ - protected void super_onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - @Override protected void onCreate(Bundle savedInstanceState) { // Use a specialized prompt when we're handling the 'Home' app startActivity() @@ -389,18 +353,15 @@ public class ResolverActivity extends FragmentActivity implements setTheme(appliedThemeResId()); super.onCreate(savedInstanceState); - mQuietModeManager = createQuietModeManager(); - // Determine whether we should show that intent is forwarded // from managed profile to owner or other way around. setProfileSwitchMessage(intent.getContentUserHint()); - mLaunchedFromUid = getLaunchedFromUid(); - if (mLaunchedFromUid < 0 || UserHandle.isIsolated(mLaunchedFromUid)) { - // Gulp! - finish(); - return; - } + // Force computation of user handle annotations in order to validate the caller ID. (See the + // associated TODO comment to explain why this is structured as a lazy computation.) + AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get(); + + mWorkProfileAvailability = createWorkProfileAvailabilityManager(); mPm = getPackageManager(); @@ -490,48 +451,6 @@ public class ResolverActivity extends FragmentActivity implements return resolverMultiProfilePagerAdapter; } - @VisibleForTesting - protected MyUserIdProvider createMyUserIdProvider() { - return new MyUserIdProvider(); - } - - @VisibleForTesting - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - return new CrossProfileIntentsChecker(getContentResolver()); - } - - @VisibleForTesting - protected QuietModeManager createQuietModeManager() { - UserManager userManager = getSystemService(UserManager.class); - return new QuietModeManager() { - - private boolean mIsWaitingToEnableWorkProfile = false; - - @Override - public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { - return userManager.isQuietModeEnabled(workProfileUserHandle); - } - - @Override - public void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle) { - AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { - userManager.requestQuietModeEnabled(enabled, workProfileUserHandle); - }); - mIsWaitingToEnableWorkProfile = true; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() { - mIsWaitingToEnableWorkProfile = false; - } - - @Override - public boolean isWaitingToEnableWorkProfile() { - return mIsWaitingToEnableWorkProfile; - } - }; - } - protected EmptyStateProvider createBlockerEmptyStateProvider() { final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); @@ -549,7 +468,8 @@ public class ResolverActivity extends FragmentActivity implements /* defaultSubtitleResource= */ R.string.resolver_cant_access_personal_apps_explanation, /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); + /* devicePolicyEventCategory= */ + ResolverActivity.METRICS_CATEGORY_RESOLVER); final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState = new DevicePolicyBlockerEmptyState(/* context= */ this, @@ -559,24 +479,605 @@ public class ResolverActivity extends FragmentActivity implements /* defaultSubtitleResource= */ R.string.resolver_cant_access_work_apps_explanation, /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER); + /* devicePolicyEventCategory= */ + ResolverActivity.METRICS_CATEGORY_RESOLVER); return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, createCrossProfileIntentsChecker(), createMyUserIdProvider()); } - protected EmptyStateProvider createEmptyStateProvider( + protected int appliedThemeResId() { + return R.style.Theme_DeviceDefault_Resolver; + } + + /** + * Numerous layouts are supported, each with optional ViewGroups. + * Make sure the inset gets added to the correct View, using + * a footer for Lists so it can properly scroll under the navbar. + */ + protected boolean shouldAddFooterView() { + if (useLayoutWithDefault()) return true; + + View buttonBar = findViewById(com.android.internal.R.id.button_bar); + if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true; + + return false; + } + + protected void applyFooterView(int height) { + if (mFooterSpacer == null) { + mFooterSpacer = new Space(getApplicationContext()); + } else { + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .getActiveAdapterView().removeFooterView(mFooterSpacer); + } + mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, + mSystemWindowInsets.bottom)); + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .getActiveAdapterView().addFooterView(mFooterSpacer); + } + + protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + mSystemWindowInsets = insets.getSystemWindowInsets(); + + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + + resetButtonBar(); + + if (shouldUseMiniResolver()) { + View buttonContainer = findViewById(com.android.internal.R.id.button_bar_container); + buttonContainer.setPadding(0, 0, 0, mSystemWindowInsets.bottom + + getResources().getDimensionPixelOffset(R.dimen.resolver_button_bar_spacing)); + } + + // Need extra padding so the list can fully scroll up + if (shouldAddFooterView()) { + applyFooterView(mSystemWindowInsets.bottom); + } + + return insets.consumeSystemWindowInsets(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() + && !shouldUseMiniResolver()) { + updateIntentPickerPaddings(); + } + + if (mSystemWindowInsets != null) { + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + } + } + + public int getLayoutResource() { + return R.layout.resolver_list; + } + + @Override + protected void onStop() { + super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mResolvingHome && !mRetainInOnStop) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } + // TODO: should we clean up the work-profile manager before we potentially finish() above? + mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mMultiProfilePagerAdapter != null + && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { + mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); + } + } + + public void onButtonClick(View v) { + final int id = v.getId(); + ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + int which = currentListAdapter.hasFilteredItem() + ? currentListAdapter.getFilteredPosition() + : listView.getCheckedItemPosition(); + boolean hasIndexBeenFiltered = !currentListAdapter.hasFilteredItem(); + startSelected(which, id == com.android.internal.R.id.button_always, hasIndexBeenFiltered); + } + + public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) { + if (isFinishing()) { + return; + } + ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(which, hasIndexBeenFiltered); + if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { + Toast.makeText(this, + getWorkProfileNotSupportedMsg( + ri.activityInfo.loadLabel(getPackageManager()).toString()), + Toast.LENGTH_LONG).show(); + return; + } + + TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(which, hasIndexBeenFiltered); + if (target == null) { + return; + } + if (onTargetSelected(target, always)) { + if (always && mSupportsAlwaysUseOption) { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); + } else if (mSupportsAlwaysUseOption) { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); + } else { + MetricsLogger.action( + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP); + } + MetricsLogger.action(this, + mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED + : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + finish(); + } + } + + /** + * Replace me in subclasses! + */ + @Override // ResolverListCommunicator + public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { + return defIntent; + } + + protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { + final ItemClickListener listener = new ItemClickListener(); + setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); + if (shouldShowTabs() && mIsIntentPicker) { + final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setMaxCollapsedHeight(getResources() + .getDimensionPixelSize(useLayoutWithDefault() + ? R.dimen.resolver_max_collapsed_height_with_default_with_tabs + : R.dimen.resolver_max_collapsed_height_with_tabs)); + } + } + } + + protected boolean onTargetSelected(TargetInfo target, boolean always) { + final ResolveInfo ri = target.getResolveInfo(); + final Intent intent = target != null ? target.getResolvedIntent() : null; + + if (intent != null && (mSupportsAlwaysUseOption + || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) + && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { + // Build a reasonable intent filter, based on what matched. + IntentFilter filter = new IntentFilter(); + Intent filterIntent; + + if (intent.getSelector() != null) { + filterIntent = intent.getSelector(); + } else { + filterIntent = intent; + } + + String action = filterIntent.getAction(); + if (action != null) { + filter.addAction(action); + } + Set<String> categories = filterIntent.getCategories(); + if (categories != null) { + for (String cat : categories) { + filter.addCategory(cat); + } + } + filter.addCategory(Intent.CATEGORY_DEFAULT); + + int cat = ri.match & IntentFilter.MATCH_CATEGORY_MASK; + Uri data = filterIntent.getData(); + if (cat == IntentFilter.MATCH_CATEGORY_TYPE) { + String mimeType = filterIntent.resolveType(this); + if (mimeType != null) { + try { + filter.addDataType(mimeType); + } catch (IntentFilter.MalformedMimeTypeException e) { + Log.w("ResolverActivity", e); + filter = null; + } + } + } + if (data != null && data.getScheme() != null) { + // We need the data specification if there was no type, + // OR if the scheme is not one of our magical "file:" + // or "content:" schemes (see IntentFilter for the reason). + if (cat != IntentFilter.MATCH_CATEGORY_TYPE + || (!"file".equals(data.getScheme()) + && !"content".equals(data.getScheme()))) { + filter.addDataScheme(data.getScheme()); + + // Look through the resolved filter to determine which part + // of it matched the original Intent. + Iterator<PatternMatcher> pIt = ri.filter.schemeSpecificPartsIterator(); + if (pIt != null) { + String ssp = data.getSchemeSpecificPart(); + while (ssp != null && pIt.hasNext()) { + PatternMatcher p = pIt.next(); + if (p.match(ssp)) { + filter.addDataSchemeSpecificPart(p.getPath(), p.getType()); + break; + } + } + } + Iterator<IntentFilter.AuthorityEntry> aIt = ri.filter.authoritiesIterator(); + if (aIt != null) { + while (aIt.hasNext()) { + IntentFilter.AuthorityEntry a = aIt.next(); + if (a.match(data) >= 0) { + int port = a.getPort(); + filter.addDataAuthority(a.getHost(), + port >= 0 ? Integer.toString(port) : null); + break; + } + } + } + pIt = ri.filter.pathsIterator(); + if (pIt != null) { + String path = data.getPath(); + while (path != null && pIt.hasNext()) { + PatternMatcher p = pIt.next(); + if (p.match(path)) { + filter.addDataPath(p.getPath(), p.getType()); + break; + } + } + } + } + } + + if (filter != null) { + final int N = mMultiProfilePagerAdapter.getActiveListAdapter() + .getUnfilteredResolveList().size(); + ComponentName[] set; + // If we don't add back in the component for forwarding the intent to a managed + // profile, the preferred activity may not be updated correctly (as the set of + // components we tell it we knew about will have changed). + final boolean needToAddBackProfileForwardingComponent = + mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null; + if (!needToAddBackProfileForwardingComponent) { + set = new ComponentName[N]; + } else { + set = new ComponentName[N + 1]; + } + + int bestMatch = 0; + for (int i=0; i<N; i++) { + ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter() + .getUnfilteredResolveList().get(i).getResolveInfoAt(0); + set[i] = new ComponentName(r.activityInfo.packageName, + r.activityInfo.name); + if (r.match > bestMatch) bestMatch = r.match; + } + + if (needToAddBackProfileForwardingComponent) { + set[N] = mMultiProfilePagerAdapter.getActiveListAdapter() + .getOtherProfile().getResolvedComponentName(); + final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter() + .getOtherProfile().getResolveInfo().match; + if (otherProfileMatch > bestMatch) bestMatch = otherProfileMatch; + } + + if (always) { + final int userId = getUserId(); + final PackageManager pm = getPackageManager(); + + // Set the preferred Activity + pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); + + if (ri.handleAllWebDataURI) { + // Set default Browser if needed + final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId); + if (TextUtils.isEmpty(packageName)) { + pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId); + } + } + } else { + try { + mMultiProfilePagerAdapter.getActiveListAdapter() + .mResolverListController.setLastChosen(intent, filter, bestMatch); + } catch (RemoteException re) { + Log.d(TAG, "Error calling setLastChosenActivity\n" + re); + } + } + } + } + + if (target != null) { + safelyStartActivity(target); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + if (target.isSuspended()) { + return false; + } + } + + return true; + } + + public void onActivityStarted(TargetInfo cti) { + // Do nothing + } + + @Override // ResolverListCommunicator + public boolean shouldGetActivityMetadata() { + return false; + } + + public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { + return !target.isSuspended(); + } + + // TODO: this method takes an unused `UserHandle` because the override in `ChooserActivity` uses + // that data to set up other components as dependencies of the controller. In reality, these + // methods don't require polymorphism, because they're only invoked from within their respective + // concrete class; `ResolverActivity` will never call this method expecting to get a + // `ChooserListController` (subclass) result, because `ResolverActivity` only invokes this + // method as part of handling `createMultiProfilePagerAdapter()`, which is itself overridden in + // `ChooserActivity`. A future refactoring could better express the coupling between the adapter + // and controller types; in the meantime, structuring as an override (with matching signatures) + // shows that these methods are *structurally* related, and helps to prevent any regressions in + // the future if resolver *were* to make any (non-overridden) calls to a version that used a + // different signature (and thus didn't return the subclass type). + @VisibleForTesting + protected ResolverListController createListController(UserHandle unused) { + return new ResolverListController( + this, + mPm, + getTargetIntent(), + getReferrerPackageName(), + getAnnotatedUserHandles().userIdOfCallingApp); + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList. + * @param rebuildCompleted + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + protected boolean postRebuildList(boolean rebuildCompleted) { + return postRebuildListInternal(rebuildCompleted); + } + + void onHorizontalSwipeStateChanged(int state) {} + + /** + * Callback called when user changes the profile tab. + * <p>This method is intended to be overridden by subclasses. + */ + protected void onProfileTabSelected() { } + + /** + * Add a label to signify that the user can pick a different app. + * @param adapter The adapter used to provide data to item views. + */ + public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { + final boolean useHeader = adapter.hasFilteredItem(); + if (useHeader) { + FrameLayout stub = findViewById(com.android.internal.R.id.stub); + stub.setVisibility(View.VISIBLE); + TextView textView = (TextView) LayoutInflater.from(this).inflate( + R.layout.resolver_different_item_header, null, false); + if (shouldShowTabs()) { + textView.setGravity(Gravity.CENTER); + } + stub.addView(textView); + } + } + + protected void resetButtonBar() { + if (!mSupportsAlwaysUseOption) { + return; + } + final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); + if (buttonLayout == null) { + Log.e(TAG, "Layout unexpectedly does not have a button bar"); + return; + } + ResolverListAdapter activeListAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider); + if (!useLayoutWithDefault()) { + int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; + buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(), + buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize( + R.dimen.resolver_button_bar_spacing) + inset); + } + if (activeListAdapter.isTabLoaded() + && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter) + && !useLayoutWithDefault()) { + buttonLayout.setVisibility(View.INVISIBLE); + if (buttonBarDivider != null) { + buttonBarDivider.setVisibility(View.INVISIBLE); + } + setButtonBarIgnoreOffset(/* ignoreOffset */ false); + return; + } + if (buttonBarDivider != null) { + buttonBarDivider.setVisibility(View.VISIBLE); + } + buttonLayout.setVisibility(View.VISIBLE); + setButtonBarIgnoreOffset(/* ignoreOffset */ true); + + mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once); + mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always); + + resetAlwaysOrOnceButtonBar(); + } + + protected String getMetricsCategory() { + return METRICS_CATEGORY_RESOLVER; + } + + @Override // ResolverListCommunicator + public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { + if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) + && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { + // We have just turned on the work profile and entered the pass code to start it, + // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no + // point in reloading the list now, since the work profile user is still + // turning on. + return; + } + boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true); + if (listRebuilt) { + ResolverListAdapter activeListAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + activeListAdapter.notifyDataSetChanged(); + if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) { + // We no longer have any items... just finish the activity. + finish(); + } + } + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + } + + protected void maybeLogProfileChange() {} + + // @NonFinalForTesting + @VisibleForTesting + protected MyUserIdProvider createMyUserIdProvider() { + return new MyUserIdProvider(); + } + + // @NonFinalForTesting + @VisibleForTesting + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + return new CrossProfileIntentsChecker(getContentResolver()); + } + + // @NonFinalForTesting + @VisibleForTesting + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + final UserHandle workUser = getWorkProfileUserHandle(); + + return new WorkProfileAvailabilityManager( + getSystemService(UserManager.class), + workUser, + () -> { + if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(workUser)) { + mMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + }); + } + + // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. + // @NonFinalForTesting + @Nullable + protected UserHandle getWorkProfileUserHandle() { + return getAnnotatedUserHandles().workProfileUserHandle; + } + + // @NonFinalForTesting + @VisibleForTesting + public void safelyStartActivity(TargetInfo cti) { + // We're dispatching intents that might be coming from legacy apps, so + // don't kill ourselves. + StrictMode.disableDeathOnFileUriExposure(); + try { + UserHandle currentUserHandle = mMultiProfilePagerAdapter.getCurrentUserHandle(); + safelyStartActivityInternal(cti, currentUserHandle, null); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } + + // @NonFinalForTesting + @VisibleForTesting + protected ResolverListAdapter createResolverListAdapter(Context context, + List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList, + boolean filterLastUsed, UserHandle userHandle) { + Intent startIntent = getIntent(); + boolean isAudioCaptureDevice = + startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + return new ResolverListAdapter( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + createListController(userHandle), + userHandle, + getTargetIntent(), + this, + isAudioCaptureDevice); + } + + private LatencyTracker getLatencyTracker() { + return LatencyTracker.getInstance(this); + } + + /** + * Get the string resource to be used as a label for the link to the resolver activity for an + * action. + * + * @param action The action to resolve + * + * @return The string resource to be used as a label + */ + public static @StringRes int getLabelRes(String action) { + return ActionTitle.forAction(action).labelRes; + } + + protected final EmptyStateProvider createEmptyStateProvider( @Nullable UserHandle workProfileUserHandle) { final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); final EmptyStateProvider workProfileOffEmptyStateProvider = new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mQuietModeManager, + mWorkProfileAvailability, /* onSwitchOnWorkSelectedListener= */ - () -> { if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - }}, + () -> { + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + }, getMetricsCategory()); final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( @@ -595,9 +1096,32 @@ public class ResolverActivity extends FragmentActivity implements ); } - private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List<ResolveInfo> rList, boolean filterLastUsed) { + private Intent makeMyIntent() { + Intent intent = new Intent(getIntent()); + intent.setComponent(null); + // The resolver activity is set to be hidden from recent tasks. + // we don't want this attribute to be propagated to the next activity + // being launched. Note that if the original Intent also had this + // flag set, we are now losing it. That should be a very rare case + // and we can live with this. + intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + return intent; + } + + /** + * Call {@link Activity#onCreate} without initializing anything further. This should + * only be used when the activity is about to be immediately finished to avoid wasting + * initializing steps and leaking resources. + */ + protected final void super_onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + private ResolverMultiProfilePagerAdapter + createResolverMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed) { ResolverListAdapter adapter = createResolverListAdapter( /* context */ this, /* payloadIntents */ mIntents, @@ -605,12 +1129,11 @@ public class ResolverActivity extends FragmentActivity implements rList, filterLastUsed, /* userHandle */ UserHandle.of(UserHandle.myUserId())); - QuietModeManager quietModeManager = createQuietModeManager(); return new ResolverMultiProfilePagerAdapter( /* context */ this, adapter, createEmptyStateProvider(/* workProfileUserHandle= */ null), - quietModeManager, + /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null); } @@ -661,28 +1184,23 @@ public class ResolverActivity extends FragmentActivity implements (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), /* userHandle */ workProfileUserHandle); - QuietModeManager quietModeManager = createQuietModeManager(); return new ResolverMultiProfilePagerAdapter( /* context */ this, personalAdapter, workAdapter, createEmptyStateProvider(getWorkProfileUserHandle()), - quietModeManager, + () -> mWorkProfileAvailability.isQuietModeEnabled(), selectedProfile, getWorkProfileUserHandle()); } - protected int appliedThemeResId() { - return R.style.Theme_DeviceDefault_Resolver; - } - /** * Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. * @throws IllegalArgumentException if the value passed to the {@link #EXTRA_SELECTED_PROFILE} * extra is not {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} */ - int getSelectedProfileExtra() { + final int getSelectedProfileExtra() { int selectedProfile = -1; if (getIntent().hasExtra(EXTRA_SELECTED_PROFILE)) { selectedProfile = getIntent().getIntExtra(EXTRA_SELECTED_PROFILE, /* defValue = */ -1); @@ -695,43 +1213,27 @@ public class ResolverActivity extends FragmentActivity implements return selectedProfile; } - protected @Profile int getCurrentProfile() { + protected final @Profile int getCurrentProfile() { return (UserHandle.myUserId() == UserHandle.USER_SYSTEM ? PROFILE_PERSONAL : PROFILE_WORK); } - protected UserHandle getPersonalProfileUserHandle() { - return UserHandle.of(ActivityManager.getCurrentUser()); + protected final AnnotatedUserHandles getAnnotatedUserHandles() { + return mLazyAnnotatedUserHandles.get(); } - @Nullable - protected UserHandle getWorkProfileUserHandle() { - return mLazyWorkProfileUserHandle.get(); - } - - @Nullable - private UserHandle fetchWorkProfileUserProfile() { - UserManager userManager = getSystemService(UserManager.class); - if (userManager == null) { - return null; - } - UserHandle result = null; - for (final UserInfo userInfo : userManager.getProfiles(ActivityManager.getCurrentUser())) { - if (userInfo.isManagedProfile()) { - result = userInfo.getUserHandle(); - } - } - return result; + protected final UserHandle getPersonalProfileUserHandle() { + return getAnnotatedUserHandles().personalProfileUserHandle; } private boolean hasWorkProfile() { return getWorkProfileUserHandle() != null; } - protected boolean shouldShowTabs() { + protected final boolean shouldShowTabs() { return hasWorkProfile(); } - protected void onProfileClick(View v) { + protected final void onProfileClick(View v) { final DisplayResolveInfo dri = mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); if (dri == null) { @@ -745,70 +1247,6 @@ public class ResolverActivity extends FragmentActivity implements finish(); } - /** - * Numerous layouts are supported, each with optional ViewGroups. - * Make sure the inset gets added to the correct View, using - * a footer for Lists so it can properly scroll under the navbar. - */ - protected boolean shouldAddFooterView() { - if (useLayoutWithDefault()) return true; - - View buttonBar = findViewById(com.android.internal.R.id.button_bar); - if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true; - - return false; - } - - protected void applyFooterView(int height) { - if (mFooterSpacer == null) { - mFooterSpacer = new Space(getApplicationContext()); - } else { - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().removeFooterView(mFooterSpacer); - } - mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, - mSystemWindowInsets.bottom)); - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().addFooterView(mFooterSpacer); - } - - protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - mSystemWindowInsets = insets.getSystemWindowInsets(); - - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - - resetButtonBar(); - - if (shouldUseMiniResolver()) { - View buttonContainer = findViewById(com.android.internal.R.id.button_bar_container); - buttonContainer.setPadding(0, 0, 0, mSystemWindowInsets.bottom - + getResources().getDimensionPixelOffset(R.dimen.resolver_button_bar_spacing)); - } - - // Need extra padding so the list can fully scroll up - if (shouldAddFooterView()) { - applyFooterView(mSystemWindowInsets.bottom); - } - - return insets.consumeSystemWindowInsets(); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() - && !shouldUseMiniResolver()) { - updateIntentPickerPaddings(); - } - - if (mSystemWindowInsets != null) { - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - } - } - private void updateIntentPickerPaddings() { View titleCont = findViewById(com.android.internal.R.id.title_container); titleCont.setPadding( @@ -824,8 +1262,20 @@ public class ResolverActivity extends FragmentActivity implements getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing)); } + private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { + if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + return; + } + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) + .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle())) + .setStrings(getMetricsCategory(), + cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") + .write(); + } + @Override // ResolverListCommunicator - public void sendVoiceChoicesIfNeeded() { + public final void sendVoiceChoicesIfNeeded() { if (!isVoiceInteraction()) { // Clearly not needed. return; @@ -833,7 +1283,7 @@ public class ResolverActivity extends FragmentActivity implements int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount(); final Option[] options = new Option[count]; - for (int i = 0, N = options.length; i < N; i++) { + for (int i = 0; i < options.length; i++) { TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); if (target == null) { // If this occurs, a new set of targets is being loaded. Let that complete, @@ -848,7 +1298,7 @@ public class ResolverActivity extends FragmentActivity implements getVoiceInteractor().submitRequest(mPickOptionRequest); } - Option optionForChooserTarget(TargetInfo target, int index) { + final Option optionForChooserTarget(TargetInfo target, int index) { return new Option(target.getDisplayLabel(), index); } @@ -860,11 +1310,11 @@ public class ResolverActivity extends FragmentActivity implements } } - public Intent getTargetIntent() { + public final Intent getTargetIntent() { return mIntents.isEmpty() ? null : mIntents.get(0); } - protected String getReferrerPackageName() { + protected final String getReferrerPackageName() { final Uri referrer = getReferrer(); if (referrer != null && "android-app".equals(referrer.getScheme())) { return referrer.getHost(); @@ -872,12 +1322,8 @@ public class ResolverActivity extends FragmentActivity implements return null; } - public int getLayoutResource() { - return R.layout.resolver_list; - } - @Override // ResolverListCommunicator - public void updateProfileViewButton() { + public final void updateProfileViewButton() { if (mProfileView == null) { return; } @@ -897,8 +1343,8 @@ public class ResolverActivity extends FragmentActivity implements } private void setProfileSwitchMessage(int contentUserHint) { - if (contentUserHint != UserHandle.USER_CURRENT && - contentUserHint != UserHandle.myUserId()) { + if ((contentUserHint != UserHandle.USER_CURRENT) + && (contentUserHint != UserHandle.myUserId())) { UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); UserInfo originUserInfo = userManager.getUserInfo(contentUserHint); boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile() @@ -936,11 +1382,11 @@ public class ResolverActivity extends FragmentActivity implements * 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 void setSafeForwardingMode(boolean safeForwarding) { + public final void setSafeForwardingMode(boolean safeForwarding) { mSafeForwardingMode = safeForwarding; } - protected CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { + protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { final ActionTitle title = mResolvingHome ? ActionTitle.HOME : ActionTitle.forAction(intent.getAction()); @@ -959,14 +1405,14 @@ public class ResolverActivity extends FragmentActivity implements } } - void dismiss() { + final void dismiss() { if (!isFinishing()) { finish(); } } @Override - protected void onRestart() { + protected final void onRestart() { super.onRestart(); if (!mRegistered) { mPersonalPackageMonitor.register(this, getMainLooper(), @@ -981,9 +1427,9 @@ public class ResolverActivity extends FragmentActivity implements } mRegistered = true; } - if (shouldShowTabs() && mQuietModeManager.isWaitingToEnableWorkProfile()) { - if (mQuietModeManager.isQuietModeEnabled(getWorkProfileUserHandle())) { - mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); + if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { + if (mWorkProfileAvailability.isQuietModeEnabled()) { + mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived(); } } mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); @@ -991,84 +1437,17 @@ public class ResolverActivity extends FragmentActivity implements } @Override - protected void onStart() { + protected final void onStart() { super.onStart(); this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); if (shouldShowTabs()) { - mWorkProfileStateReceiver = createWorkProfileStateReceiver(); - registerWorkProfileStateReceiver(); - - mWorkProfileHasBeenEnabled = isWorkProfileEnabled(); - } - } - - private boolean isWorkProfileEnabled() { - UserHandle workUserHandle = getWorkProfileUserHandle(); - UserManager userManager = getSystemService(UserManager.class); - - return !userManager.isQuietModeEnabled(workUserHandle) - && userManager.isUserUnlocked(workUserHandle); - } - - private void registerWorkProfileStateReceiver() { - IntentFilter filter = new IntentFilter(); - filter.addAction(Intent.ACTION_USER_UNLOCKED); - filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE); - filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE); - registerReceiverAsUser(mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null); - } - - @Override - protected void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mResolvingHome && !mRetainInOnStop) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - if (mWorkPackageMonitor != null) { - unregisterReceiver(mWorkProfileStateReceiver); - mWorkPackageMonitor = null; + mWorkProfileAvailability.registerWorkProfileStateReceiver(this); } } @Override - protected void onDestroy() { - super.onDestroy(); - if (!isChangingConfigurations() && mPickOptionRequest != null) { - mPickOptionRequest.cancel(); - } - if (mMultiProfilePagerAdapter != null - && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { - mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); - } - } - - @Override - protected void onSaveInstanceState(Bundle outState) { + protected final void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); if (viewPager != null) { @@ -1077,7 +1456,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override - protected void onRestoreInstanceState(Bundle savedInstanceState) { + protected final void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); resetButtonBar(); ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); @@ -1161,55 +1540,6 @@ public class ResolverActivity extends FragmentActivity implements mAlwaysButton.setEnabled(enabled); } - public void onButtonClick(View v) { - final int id = v.getId(); - ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); - ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); - int which = currentListAdapter.hasFilteredItem() - ? currentListAdapter.getFilteredPosition() - : listView.getCheckedItemPosition(); - boolean hasIndexBeenFiltered = !currentListAdapter.hasFilteredItem(); - startSelected(which, id == com.android.internal.R.id.button_always, hasIndexBeenFiltered); - } - - public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) { - if (isFinishing()) { - return; - } - ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() - .resolveInfoForPosition(which, hasIndexBeenFiltered); - if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { - Toast.makeText(this, - getWorkProfileNotSupportedMsg( - ri.activityInfo.loadLabel(getPackageManager()).toString()), - Toast.LENGTH_LONG).show(); - return; - } - - TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(which, hasIndexBeenFiltered); - if (target == null) { - return; - } - if (onTargetSelected(target, always)) { - if (always && mSupportsAlwaysUseOption) { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); - } else if (mSupportsAlwaysUseOption) { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); - } else { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP); - } - MetricsLogger.action(this, - mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() - ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED - : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); - finish(); - } - } - private String getWorkProfileNotSupportedMsg(String launcherName) { return getSystemService(DevicePolicyManager.class).getResources().getString( RESOLVER_WORK_PROFILE_NOT_SUPPORTED, @@ -1219,14 +1549,6 @@ public class ResolverActivity extends FragmentActivity implements launcherName); } - /** - * Replace me in subclasses! - */ - @Override // ResolverListCommunicator - public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - return defIntent; - } - @Override // ResolverListCommunicator public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, boolean rebuildCompleted) { @@ -1254,204 +1576,17 @@ public class ResolverActivity extends FragmentActivity implements } } - protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { - final ItemClickListener listener = new ItemClickListener(); - setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); - if (shouldShowTabs() && mIsIntentPicker) { - final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); - if (rdl != null) { - rdl.setMaxCollapsedHeight(getResources() - .getDimensionPixelSize(useLayoutWithDefault() - ? R.dimen.resolver_max_collapsed_height_with_default_with_tabs - : R.dimen.resolver_max_collapsed_height_with_tabs)); - } - } - } - - protected boolean onTargetSelected(TargetInfo target, boolean always) { - final ResolveInfo ri = target.getResolveInfo(); - final Intent intent = target != null ? target.getResolvedIntent() : null; - - if (intent != null && (mSupportsAlwaysUseOption - || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) - && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { - // Build a reasonable intent filter, based on what matched. - IntentFilter filter = new IntentFilter(); - Intent filterIntent; - - if (intent.getSelector() != null) { - filterIntent = intent.getSelector(); - } else { - filterIntent = intent; - } - - String action = filterIntent.getAction(); - if (action != null) { - filter.addAction(action); - } - Set<String> categories = filterIntent.getCategories(); - if (categories != null) { - for (String cat : categories) { - filter.addCategory(cat); - } - } - filter.addCategory(Intent.CATEGORY_DEFAULT); - - int cat = ri.match & IntentFilter.MATCH_CATEGORY_MASK; - Uri data = filterIntent.getData(); - if (cat == IntentFilter.MATCH_CATEGORY_TYPE) { - String mimeType = filterIntent.resolveType(this); - if (mimeType != null) { - try { - filter.addDataType(mimeType); - } catch (IntentFilter.MalformedMimeTypeException e) { - Log.w("ResolverActivity", e); - filter = null; - } - } - } - if (data != null && data.getScheme() != null) { - // We need the data specification if there was no type, - // OR if the scheme is not one of our magical "file:" - // or "content:" schemes (see IntentFilter for the reason). - if (cat != IntentFilter.MATCH_CATEGORY_TYPE - || (!"file".equals(data.getScheme()) - && !"content".equals(data.getScheme()))) { - filter.addDataScheme(data.getScheme()); - - // Look through the resolved filter to determine which part - // of it matched the original Intent. - Iterator<PatternMatcher> pIt = ri.filter.schemeSpecificPartsIterator(); - if (pIt != null) { - String ssp = data.getSchemeSpecificPart(); - while (ssp != null && pIt.hasNext()) { - PatternMatcher p = pIt.next(); - if (p.match(ssp)) { - filter.addDataSchemeSpecificPart(p.getPath(), p.getType()); - break; - } - } - } - Iterator<IntentFilter.AuthorityEntry> aIt = ri.filter.authoritiesIterator(); - if (aIt != null) { - while (aIt.hasNext()) { - IntentFilter.AuthorityEntry a = aIt.next(); - if (a.match(data) >= 0) { - int port = a.getPort(); - filter.addDataAuthority(a.getHost(), - port >= 0 ? Integer.toString(port) : null); - break; - } - } - } - pIt = ri.filter.pathsIterator(); - if (pIt != null) { - String path = data.getPath(); - while (path != null && pIt.hasNext()) { - PatternMatcher p = pIt.next(); - if (p.match(path)) { - filter.addDataPath(p.getPath(), p.getType()); - break; - } - } - } - } - } - - if (filter != null) { - final int N = mMultiProfilePagerAdapter.getActiveListAdapter() - .getUnfilteredResolveList().size(); - ComponentName[] set; - // If we don't add back in the component for forwarding the intent to a managed - // profile, the preferred activity may not be updated correctly (as the set of - // components we tell it we knew about will have changed). - final boolean needToAddBackProfileForwardingComponent = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null; - if (!needToAddBackProfileForwardingComponent) { - set = new ComponentName[N]; - } else { - set = new ComponentName[N + 1]; - } - - int bestMatch = 0; - for (int i=0; i<N; i++) { - ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter() - .getUnfilteredResolveList().get(i).getResolveInfoAt(0); - set[i] = new ComponentName(r.activityInfo.packageName, - r.activityInfo.name); - if (r.match > bestMatch) bestMatch = r.match; - } - - if (needToAddBackProfileForwardingComponent) { - set[N] = mMultiProfilePagerAdapter.getActiveListAdapter() - .getOtherProfile().getResolvedComponentName(); - final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter() - .getOtherProfile().getResolveInfo().match; - if (otherProfileMatch > bestMatch) bestMatch = otherProfileMatch; - } - - if (always) { - final int userId = getUserId(); - final PackageManager pm = getPackageManager(); - - // Set the preferred Activity - pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); - - if (ri.handleAllWebDataURI) { - // Set default Browser if needed - final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId); - if (TextUtils.isEmpty(packageName)) { - pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId); - } - } - } else { - try { - mMultiProfilePagerAdapter.getActiveListAdapter() - .mResolverListController.setLastChosen(intent, filter, bestMatch); - } catch (RemoteException re) { - Log.d(TAG, "Error calling setLastChosenActivity\n" + re); - } - } - } - } - - if (target != null) { - safelyStartActivity(target); - - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - if (target.isSuspended()) { - return false; - } - } - - return true; - } - - @VisibleForTesting - public void safelyStartActivity(TargetInfo cti) { - // We're dispatching intents that might be coming from legacy apps, so - // don't kill ourselves. - StrictMode.disableDeathOnFileUriExposure(); - try { - UserHandle currentUserHandle = mMultiProfilePagerAdapter.getCurrentUserHandle(); - safelyStartActivityInternal(cti, currentUserHandle, null); - } finally { - StrictMode.enableDeathOnFileUriExposure(); - } - } - /** * Start activity as a fixed user handle. * @param cti TargetInfo to be launched. * @param user User to launch this activity as. */ - @VisibleForTesting - public void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) + public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { safelyStartActivityAsUser(cti, user, null); } - protected void safelyStartActivityAsUser( + protected final void safelyStartActivityAsUser( TargetInfo cti, UserHandle user, @Nullable Bundle options) { // We're dispatching intents that might be coming from legacy apps, so // don't kill ourselves. @@ -1494,76 +1629,20 @@ public class ResolverActivity extends FragmentActivity implements maybeLogCrossProfileTargetLaunch(cti, user); } } catch (RuntimeException e) { - Slog.wtf(TAG, "Unable to launch as uid " + mLaunchedFromUid + Slog.wtf(TAG, + "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp + " package " + getLaunchedFromPackage() + ", while running in " + ActivityThread.currentProcessName(), e); } } - private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { - return; - } - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle())) - .setStrings(getMetricsCategory(), - cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") - .write(); - } - - - public void onActivityStarted(TargetInfo cti) { - // Do nothing - } - - @Override // ResolverListCommunicator - public boolean shouldGetActivityMetadata() { - return false; - } - - public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - return !target.isSuspended(); - } - - void showTargetDetails(ResolveInfo ri) { + final void showTargetDetails(ResolveInfo ri) { Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) .addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle()); } - @VisibleForTesting - protected ResolverListAdapter createResolverListAdapter(Context context, - List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList, - boolean filterLastUsed, UserHandle userHandle) { - Intent startIntent = getIntent(); - boolean isAudioCaptureDevice = - startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); - return new ResolverListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - getTargetIntent(), - this, - isAudioCaptureDevice); - } - - @VisibleForTesting - protected ResolverListController createListController(UserHandle userHandle) { - return new ResolverListController( - this, - mPm, - getTargetIntent(), - getReferrerPackageName(), - mLaunchedFromUid, - userHandle); - } - /** * Sets up the content view. * @return <code>true</code> if the activity is finishing and creation should halt. @@ -1650,8 +1729,7 @@ public class ResolverActivity extends FragmentActivity implements findViewById(com.android.internal.R.id.button_open).setOnClickListener(v -> { Intent intent = otherProfileResolveInfo.getResolvedIntent(); - safelyStartActivityAsUser(otherProfileResolveInfo, - inactiveAdapter.mResolverListController.getUserHandle()); + safelyStartActivityAsUser(otherProfileResolveInfo, inactiveAdapter.getUserHandle()); finish(); }); } @@ -1700,16 +1778,6 @@ public class ResolverActivity extends FragmentActivity implements /** * Finishing procedures to be performed after the list has been rebuilt. - * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList. - * @param rebuildCompleted - * @return <code>true</code> if the activity is finishing and creation should halt. - */ - protected boolean postRebuildList(boolean rebuildCompleted) { - return postRebuildListInternal(rebuildCompleted); - } - - /** - * Finishing procedures to be performed after the list has been rebuilt. * @param rebuildCompleted * @return <code>true</code> if the activity is finishing and creation should halt. */ @@ -1965,8 +2033,6 @@ public class ResolverActivity extends FragmentActivity implements RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab)); } - void onHorizontalSwipeStateChanged(int state) {} - private void maybeHideDivider() { if (!mIsIntentPicker) { return; @@ -1978,12 +2044,6 @@ public class ResolverActivity extends FragmentActivity implements divider.setVisibility(View.GONE); } - /** - * Callback called when user changes the profile tab. - * <p>This method is intended to be overridden by subclasses. - */ - protected void onProfileTabSelected() { } - private void resetCheckedItem() { if (!mIsIntentPicker) { return; @@ -2030,20 +2090,17 @@ public class ResolverActivity extends FragmentActivity implements } /** - * Add a label to signify that the user can pick a different app. - * @param adapter The adapter used to provide data to item views. + * Updates the button bar container {@code ignoreOffset} layout param. + * <p>Setting this to {@code true} means that the button bar will be glued to the bottom of + * the screen. */ - public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - final boolean useHeader = adapter.hasFilteredItem(); - if (useHeader) { - FrameLayout stub = findViewById(com.android.internal.R.id.stub); - stub.setVisibility(View.VISIBLE); - TextView textView = (TextView) LayoutInflater.from(this).inflate( - R.layout.resolver_different_item_header, null, false); - if (shouldShowTabs()) { - textView.setGravity(Gravity.CENTER); - } - stub.addView(textView); + private void setButtonBarIgnoreOffset(boolean ignoreOffset) { + View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container); + if (buttonBarContainer != null) { + ResolverDrawerLayout.LayoutParams layoutParams = + (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams(); + layoutParams.ignoreOffset = ignoreOffset; + buttonBarContainer.setLayoutParams(layoutParams); } } @@ -2091,61 +2148,6 @@ public class ResolverActivity extends FragmentActivity implements mHeaderCreatorUser = listAdapter.getUserHandle(); } - protected void resetButtonBar() { - if (!mSupportsAlwaysUseOption) { - return; - } - final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); - if (buttonLayout == null) { - Log.e(TAG, "Layout unexpectedly does not have a button bar"); - return; - } - ResolverListAdapter activeListAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); - View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider); - if (!useLayoutWithDefault()) { - int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; - buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(), - buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize( - R.dimen.resolver_button_bar_spacing) + inset); - } - if (activeListAdapter.isTabLoaded() - && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter) - && !useLayoutWithDefault()) { - buttonLayout.setVisibility(View.INVISIBLE); - if (buttonBarDivider != null) { - buttonBarDivider.setVisibility(View.INVISIBLE); - } - setButtonBarIgnoreOffset(/* ignoreOffset */ false); - return; - } - if (buttonBarDivider != null) { - buttonBarDivider.setVisibility(View.VISIBLE); - } - buttonLayout.setVisibility(View.VISIBLE); - setButtonBarIgnoreOffset(/* ignoreOffset */ true); - - mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once); - mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always); - - resetAlwaysOrOnceButtonBar(); - } - - /** - * Updates the button bar container {@code ignoreOffset} layout param. - * <p>Setting this to {@code true} means that the button bar will be glued to the bottom of - * the screen. - */ - private void setButtonBarIgnoreOffset(boolean ignoreOffset) { - View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container); - if (buttonBarContainer != null) { - ResolverDrawerLayout.LayoutParams layoutParams = - (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams(); - layoutParams.ignoreOffset = ignoreOffset; - buttonBarContainer.setLayoutParams(layoutParams); - } - } - private void resetAlwaysOrOnceButtonBar() { // Disable both buttons initially setAlwaysButtonEnabled(false, ListView.INVALID_POSITION, false); @@ -2171,7 +2173,7 @@ public class ResolverActivity extends FragmentActivity implements } @Override // ResolverListCommunicator - public boolean useLayoutWithDefault() { + public final boolean useLayoutWithDefault() { // We only use the default app layout when the profile of the active user has a // filtered item. We always show the same default app even in the inactive user profile. boolean currentUserAdapterHasFilteredItem; @@ -2190,7 +2192,7 @@ public class ResolverActivity extends FragmentActivity implements * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets * called and we are launched in a new task. */ - protected void setRetainInOnStop(boolean retainInOnStop) { + protected final void setRetainInOnStop(boolean retainInOnStop) { mRetainInOnStop = retainInOnStop; } @@ -2198,43 +2200,13 @@ public class ResolverActivity extends FragmentActivity implements * Check a simple match for the component of two ResolveInfos. */ @Override // ResolverListCommunicator - public boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) { + public final boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) { return lhs == null ? rhs == null : lhs.activityInfo == null ? rhs.activityInfo == null : Objects.equals(lhs.activityInfo.name, rhs.activityInfo.name) && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName); } - protected String getMetricsCategory() { - return METRICS_CATEGORY_RESOLVER; - } - - @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) - && mQuietModeManager.isWaitingToEnableWorkProfile()) { - // We have just turned on the work profile and entered the pass code to start it, - // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no - // point in reloading the list now, since the work profile user is still - // turning on. - return; - } - boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true); - if (listRebuilt) { - ResolverListAdapter activeListAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); - activeListAdapter.notifyDataSetChanged(); - if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) { - // We no longer have any items... just finish the activity. - finish(); - } - } - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - } - private boolean inactiveListAdapterHasItems() { if (!shouldShowTabs()) { return false; @@ -2242,101 +2214,7 @@ public class ResolverActivity extends FragmentActivity implements return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; } - private BroadcastReceiver createWorkProfileStateReceiver() { - return new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - if (!TextUtils.equals(action, Intent.ACTION_USER_UNLOCKED) - && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE) - && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_AVAILABLE)) { - return; - } - - int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1); - - if (userId != getWorkProfileUserHandle().getIdentifier()) { - return; - } - - if (isWorkProfileEnabled()) { - if (mWorkProfileHasBeenEnabled) { - return; - } - - mWorkProfileHasBeenEnabled = true; - mQuietModeManager.markWorkProfileEnabledBroadcastReceived(); - } else { - // Must be an UNAVAILABLE broadcast, so we watch for the next availability - mWorkProfileHasBeenEnabled = false; - } - - if (mMultiProfilePagerAdapter.getCurrentUserHandle() - .equals(getWorkProfileUserHandle())) { - mMultiProfilePagerAdapter.rebuildActiveTab(true); - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - } - }; - } - - public static final class ResolvedComponentInfo { - public final ComponentName name; - private final List<Intent> mIntents = new ArrayList<>(); - private final List<ResolveInfo> mResolveInfos = new ArrayList<>(); - private boolean mPinned; - - public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) { - this.name = name; - add(intent, info); - } - - public void add(Intent intent, ResolveInfo info) { - mIntents.add(intent); - mResolveInfos.add(info); - } - - public int getCount() { - return mIntents.size(); - } - - public Intent getIntentAt(int index) { - return index >= 0 ? mIntents.get(index) : null; - } - - public ResolveInfo getResolveInfoAt(int index) { - return index >= 0 ? mResolveInfos.get(index) : null; - } - - public int findIntent(Intent intent) { - for (int i = 0, N = mIntents.size(); i < N; i++) { - if (intent.equals(mIntents.get(i))) { - return i; - } - } - return -1; - } - - public int findResolveInfo(ResolveInfo info) { - for (int i = 0, N = mResolveInfos.size(); i < N; i++) { - if (info.equals(mResolveInfos.get(i))) { - return i; - } - } - return -1; - } - - public boolean isPinned() { - return mPinned; - } - - public void setPinned(boolean pinned) { - mPinned = pinned; - } - } - - class ItemClickListener implements AdapterView.OnItemClickListener, + final class ItemClickListener implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { @@ -2397,7 +2275,7 @@ public class ResolverActivity extends FragmentActivity implements && match <= IntentFilter.MATCH_CATEGORY_PATH; } - static class PickTargetOptionRequest extends PickOptionRequest { + static final class PickTargetOptionRequest extends PickOptionRequest { public PickTargetOptionRequest(@Nullable Prompt prompt, Option[] options, @Nullable Bundle extras) { super(prompt, options, extras); @@ -2433,6 +2311,4 @@ public class ResolverActivity extends FragmentActivity implements } } } - - protected void maybeLogProfileChange() {} } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index eecb914c..eac275cc 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -18,6 +18,7 @@ package com.android.intentresolver; import static android.content.Context.ACTIVITY_SERVICE; +import android.animation.ObjectAnimator; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.ActivityManager; @@ -42,12 +43,12 @@ import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; import android.widget.AbsListView; import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.annotations.VisibleForTesting; @@ -287,11 +288,7 @@ public class ResolverListAdapter extends BaseAdapter { mBaseResolveList); return currentResolveList; } else { - return mResolverListController.getResolversForIntent( - /* shouldGetResolvedFilter= */ true, - mResolverListCommunicator.shouldGetActivityMetadata(), - mResolverListCommunicator.shouldGetOnlyDefaultActivities(), - mIntents); + return getResolversForUser(mUserHandle); } } @@ -802,10 +799,12 @@ public class ResolverListAdapter extends BaseAdapter { } protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) { - return mResolverListController.getResolversForIntentAsUser(true, + return mResolverListController.getResolversForIntentAsUser( + /* shouldGetResolvedFilter= */ true, mResolverListCommunicator.shouldGetActivityMetadata(), mResolverListCommunicator.shouldGetOnlyDefaultActivities(), - mIntents, userHandle); + mIntents, + userHandle); } protected List<Intent> getIntents() { @@ -914,6 +913,7 @@ public class ResolverListAdapter extends BaseAdapter { */ @VisibleForTesting public static class ViewHolder { + private static final long IMAGE_FADE_IN_MILLIS = 150; public View itemView; public Drawable defaultItemViewBackground; @@ -952,7 +952,22 @@ public class ResolverListAdapter extends BaseAdapter { } public void bindIcon(TargetInfo info) { - icon.setImageDrawable(info.getDisplayIconHolder().getDisplayIcon()); + bindIcon(info, false); + } + + /** + * Bind view holder to a TargetInfo, run icon reveal animation, if required. + */ + public void bindIcon(TargetInfo info, boolean animate) { + Drawable displayIcon = info.getDisplayIconHolder().getDisplayIcon(); + boolean runAnimation = animate && (icon.getDrawable() == null) && (displayIcon != null); + icon.setImageDrawable(displayIcon); + if (runAnimation) { + ObjectAnimator animator = ObjectAnimator.ofFloat(icon, "alpha", 0.0f, 1.0f); + animator.setInterpolator(new DecelerateInterpolator(1.0f)); + animator.setDuration(IMAGE_FADE_IN_MILLIS); + animator.start(); + } if (info.isSuspended()) { icon.setColorFilter(getSuspendedColorMatrix()); } else { diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index bfffe0d8..b4544c43 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -58,7 +58,6 @@ public class ResolverListController { private static final String TAG = "ResolverListController"; private static final boolean DEBUG = false; - private final UserHandle mUserHandle; private AbstractResolverComparator mResolverComparator; private boolean isComputed = false; @@ -68,9 +67,8 @@ public class ResolverListController { PackageManager pm, Intent targetIntent, String referrerPackage, - int launchedFromUid, - UserHandle userHandle) { - this(context, pm, targetIntent, referrerPackage, launchedFromUid, userHandle, + int launchedFromUid) { + this(context, pm, targetIntent, referrerPackage, launchedFromUid, new ResolverRankerServiceResolverComparator( context, targetIntent, referrerPackage, null, null)); } @@ -81,14 +79,12 @@ public class ResolverListController { Intent targetIntent, String referrerPackage, int launchedFromUid, - UserHandle userHandle, AbstractResolverComparator resolverComparator) { mContext = context; mpm = pm; mLaunchedFromUid = launchedFromUid; mTargetIntent = targetIntent; mReferrerPackage = referrerPackage; - mUserHandle = userHandle; mResolverComparator = resolverComparator; } @@ -108,17 +104,11 @@ public class ResolverListController { filter, match, intent.getComponent()); } - @VisibleForTesting - public List<ResolverActivity.ResolvedComponentInfo> getResolversForIntent( - boolean shouldGetResolvedFilter, - boolean shouldGetActivityMetadata, - boolean shouldGetOnlyDefaultActivities, - List<Intent> intents) { - return getResolversForIntentAsUser(shouldGetResolvedFilter, shouldGetActivityMetadata, - shouldGetOnlyDefaultActivities, intents, mUserHandle); - } - - public List<ResolverActivity.ResolvedComponentInfo> getResolversForIntentAsUser( + /** + * Get data about all the ways the user with the specified handle can resolve any of the + * provided {@code intents}. + */ + public List<ResolvedComponentInfo> getResolversForIntentAsUser( boolean shouldGetResolvedFilter, boolean shouldGetActivityMetadata, boolean shouldGetOnlyDefaultActivities, @@ -132,11 +122,9 @@ public class ResolverListController { return getResolversForIntentAsUserInternal(intents, userHandle, baseFlags); } - private List<ResolverActivity.ResolvedComponentInfo> getResolversForIntentAsUserInternal( - List<Intent> intents, - UserHandle userHandle, - int baseFlags) { - List<ResolverActivity.ResolvedComponentInfo> resolvedComponents = null; + private List<ResolvedComponentInfo> getResolversForIntentAsUserInternal( + List<Intent> intents, UserHandle userHandle, int baseFlags) { + List<ResolvedComponentInfo> resolvedComponents = null; for (int i = 0, N = intents.size(); i < N; i++) { Intent intent = intents.get(i); int flags = baseFlags; @@ -160,14 +148,8 @@ public class ResolverListController { } @VisibleForTesting - public UserHandle getUserHandle() { - return mUserHandle; - } - - @VisibleForTesting - public void addResolveListDedupe(List<ResolverActivity.ResolvedComponentInfo> into, - Intent intent, - List<ResolveInfo> from) { + public void addResolveListDedupe( + List<ResolvedComponentInfo> into, Intent intent, List<ResolveInfo> from) { final int fromCount = from.size(); final int intoCount = into.size(); for (int i = 0; i < fromCount; i++) { @@ -175,7 +157,7 @@ public class ResolverListController { boolean found = false; // Only loop to the end of into as it was before we started; no dupes in from. for (int j = 0; j < intoCount; j++) { - final ResolverActivity.ResolvedComponentInfo rci = into.get(j); + final ResolvedComponentInfo rci = into.get(j); if (isSameResolvedComponent(newInfo, rci)) { found = true; rci.add(intent, newInfo); @@ -185,8 +167,7 @@ public class ResolverListController { if (!found) { final ComponentName name = new ComponentName( newInfo.activityInfo.packageName, newInfo.activityInfo.name); - final ResolverActivity.ResolvedComponentInfo rci = - new ResolverActivity.ResolvedComponentInfo(name, intent, newInfo); + final ResolvedComponentInfo rci = new ResolvedComponentInfo(name, intent, newInfo); rci.setPinned(isComponentPinned(name)); into.add(rci); } @@ -206,10 +187,9 @@ public class ResolverListController { // To preserve the inputList, optionally will return the original list if any modification has // been made. @VisibleForTesting - public ArrayList<ResolverActivity.ResolvedComponentInfo> filterIneligibleActivities( - List<ResolverActivity.ResolvedComponentInfo> inputList, - boolean returnCopyOfOriginalListIfModified) { - ArrayList<ResolverActivity.ResolvedComponentInfo> listToReturn = null; + public ArrayList<ResolvedComponentInfo> filterIneligibleActivities( + List<ResolvedComponentInfo> inputList, boolean returnCopyOfOriginalListIfModified) { + ArrayList<ResolvedComponentInfo> listToReturn = null; for (int i = inputList.size()-1; i >= 0; i--) { ActivityInfo ai = inputList.get(i) .getResolveInfoAt(0).activityInfo; @@ -235,13 +215,12 @@ public class ResolverListController { // To preserve the inputList, optionally will return the original list if any modification has // been made. @VisibleForTesting - public ArrayList<ResolverActivity.ResolvedComponentInfo> filterLowPriority( - List<ResolverActivity.ResolvedComponentInfo> inputList, - boolean returnCopyOfOriginalListIfModified) { - ArrayList<ResolverActivity.ResolvedComponentInfo> listToReturn = null; + public ArrayList<ResolvedComponentInfo> filterLowPriority( + List<ResolvedComponentInfo> inputList, boolean returnCopyOfOriginalListIfModified) { + ArrayList<ResolvedComponentInfo> listToReturn = null; // Only display the first matches that are either of equal // priority or have asked to be default options. - ResolverActivity.ResolvedComponentInfo rci0 = inputList.get(0); + ResolvedComponentInfo rci0 = inputList.get(0); ResolveInfo r0 = rci0.getResolveInfoAt(0); int N = inputList.size(); for (int i = 1; i < N; i++) { @@ -266,8 +245,7 @@ public class ResolverListController { return listToReturn; } - private void compute(List<ResolverActivity.ResolvedComponentInfo> inputList) - throws InterruptedException { + private void compute(List<ResolvedComponentInfo> inputList) throws InterruptedException { if (mResolverComparator == null) { Log.d(TAG, "Comparator has already been destroyed; skipped."); return; @@ -281,7 +259,7 @@ public class ResolverListController { @VisibleForTesting @WorkerThread - public void sort(List<ResolverActivity.ResolvedComponentInfo> inputList) { + public void sort(List<ResolvedComponentInfo> inputList) { try { long beforeRank = System.currentTimeMillis(); if (!isComputed) { @@ -300,7 +278,7 @@ public class ResolverListController { @VisibleForTesting @WorkerThread - public void topK(List<ResolverActivity.ResolvedComponentInfo> inputList, int k) { + public void topK(List<ResolvedComponentInfo> inputList, int k) { if (inputList == null || inputList.isEmpty() || k <= 0) { return; } @@ -317,7 +295,7 @@ public class ResolverListController { } // Top of this heap has lowest rank. - PriorityQueue<ResolverActivity.ResolvedComponentInfo> minHeap = new PriorityQueue<>(k, + PriorityQueue<ResolvedComponentInfo> minHeap = new PriorityQueue<>(k, (o1, o2) -> -mResolverComparator.compare(o1, o2)); final int size = inputList.size(); // Use this pointer to keep track of the position of next element @@ -325,7 +303,7 @@ public class ResolverListController { int pointer = size - 1; minHeap.addAll(inputList.subList(size - k, size)); for (int i = size - k - 1; i >= 0; --i) { - ResolverActivity.ResolvedComponentInfo ci = inputList.get(i); + ResolvedComponentInfo ci = inputList.get(i); if (-mResolverComparator.compare(ci, minHeap.peek()) > 0) { // When ranked higher than top of heap, remove top of heap, // update input list with it, add this new element to heap. @@ -354,8 +332,7 @@ public class ResolverListController { } } - private static boolean isSameResolvedComponent(ResolveInfo a, - ResolverActivity.ResolvedComponentInfo b) { + private static boolean isSameResolvedComponent(ResolveInfo a, ResolvedComponentInfo b) { final ActivityInfo ai = a.activityInfo; return ai.packageName.equals(b.name.getPackageName()) && ai.name.equals(b.name.getClassName()); diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java index 65de9409..48e3b62d 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -43,13 +43,13 @@ public class ResolverMultiProfilePagerAdapter extends Context context, ResolverListAdapter adapter, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier<Boolean> workProfileQuietModeChecker, UserHandle workProfileUserHandle) { this( context, ImmutableList.of(adapter), emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, /* defaultProfile= */ 0, workProfileUserHandle, new BottomPaddingOverrideSupplier()); @@ -59,14 +59,14 @@ public class ResolverMultiProfilePagerAdapter extends ResolverListAdapter personalAdapter, ResolverListAdapter workAdapter, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier<Boolean> workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle) { this( context, ImmutableList.of(personalAdapter, workAdapter), emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, new BottomPaddingOverrideSupplier()); @@ -76,7 +76,7 @@ public class ResolverMultiProfilePagerAdapter extends Context context, ImmutableList<ResolverListAdapter> listAdapters, EmptyStateProvider emptyStateProvider, - QuietModeManager quietModeManager, + Supplier<Boolean> workProfileQuietModeChecker, @Profile int defaultProfile, UserHandle workProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { @@ -86,7 +86,7 @@ public class ResolverMultiProfilePagerAdapter extends (listView, bindAdapter) -> listView.setAdapter(bindAdapter), listAdapters, emptyStateProvider, - quietModeManager, + workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, () -> (ViewGroup) LayoutInflater.from(context).inflate( diff --git a/java/src/com/android/intentresolver/SecureSettings.kt b/java/src/com/android/intentresolver/SecureSettings.kt new file mode 100644 index 00000000..a4853fd8 --- /dev/null +++ b/java/src/com/android/intentresolver/SecureSettings.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.content.ContentResolver +import android.provider.Settings + +/** + * A proxy class for secure settings, for easier testing. + */ +open class SecureSettings { + open fun getString(resolver: ContentResolver, name: String): String? { + return Settings.Secure.getString(resolver, name) + } +} diff --git a/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java b/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java new file mode 100644 index 00000000..6e51520b --- /dev/null +++ b/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java @@ -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; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.AsyncTask; +import android.os.UserHandle; +import android.os.UserManager; +import android.text.TextUtils; + +import androidx.annotation.VisibleForTesting; + +/** Monitor for runtime conditions that may disable work profile display. */ +public class WorkProfileAvailabilityManager { + private final UserManager mUserManager; + private final UserHandle mWorkProfileUserHandle; + private final Runnable mOnWorkProfileStateUpdated; + + private BroadcastReceiver mWorkProfileStateReceiver; + + private boolean mIsWaitingToEnableWorkProfile; + private boolean mWorkProfileHasBeenEnabled; + + public WorkProfileAvailabilityManager( + UserManager userManager, + UserHandle workProfileUserHandle, + Runnable onWorkProfileStateUpdated) { + mUserManager = userManager; + mWorkProfileUserHandle = workProfileUserHandle; + mWorkProfileHasBeenEnabled = isWorkProfileEnabled(); + mOnWorkProfileStateUpdated = onWorkProfileStateUpdated; + } + + /** + * Register a {@link BroadcastReceiver}, if we haven't already, to be notified about work + * profile availability changes. + * + * TODO: this takes the context for testing, because we don't have a context on hand when we + * set up this component's default "override" in {@link ChooserActivityOverrideData#reset()}. + * The use of these overrides in our testing design is questionable and can hopefully be + * improved someday; then this context should be injected in our constructor & held as `final`. + * + * TODO: consider injecting an optional `Lifecycle` so that this component can automatically + * manage its own registration/unregistration. (This would be optional because registration of + * the receiver is conditional on having `shouldShowTabs()` in our session.) + */ + public void registerWorkProfileStateReceiver(Context context) { + if (mWorkProfileStateReceiver != null) { + return; + } + mWorkProfileStateReceiver = createWorkProfileStateReceiver(); + + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_USER_UNLOCKED); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE); + context.registerReceiverAsUser( + mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null); + } + + /** + * Unregister any {@link BroadcastReceiver} currently waiting for a work-enabled broadcast. + * + * TODO: this takes the context for testing, because we don't have a context on hand when we + * set up this component's default "override" in {@link ChooserActivityOverrideData#reset()}. + * The use of these overrides in our testing design is questionable and can hopefully be + * improved someday; then this context should be injected in our constructor & held as `final`. + */ + public void unregisterWorkProfileStateReceiver(Context context) { + if (mWorkProfileStateReceiver == null) { + return; + } + context.unregisterReceiver(mWorkProfileStateReceiver); + mWorkProfileStateReceiver = null; + } + + public boolean isQuietModeEnabled() { + return mUserManager.isQuietModeEnabled(mWorkProfileUserHandle); + } + + // TODO: why do clients only care about the result of `isQuietModeEnabled()`, even though + // internally (in `isWorkProfileEnabled()`) we also check this 'unlocked' condition? + @VisibleForTesting + public boolean isWorkProfileUserUnlocked() { + return mUserManager.isUserUnlocked(mWorkProfileUserHandle); + } + + /** + * Request that quiet mode be enabled (or disabled) for the work profile. + * TODO: this is only used to disable quiet mode; should that be hard-coded? + */ + public void requestQuietModeEnabled(boolean enabled) { + AsyncTask.THREAD_POOL_EXECUTOR.execute( + () -> mUserManager.requestQuietModeEnabled(enabled, mWorkProfileUserHandle)); + mIsWaitingToEnableWorkProfile = true; + } + + /** + * Stop waiting for a work-enabled broadcast. + * TODO: this seems strangely low-level to include as part of the public API. Maybe some + * responsibilities need to be pulled over from the client? + */ + public void markWorkProfileEnabledBroadcastReceived() { + mIsWaitingToEnableWorkProfile = false; + } + + public boolean isWaitingToEnableWorkProfile() { + return mIsWaitingToEnableWorkProfile; + } + + private boolean isWorkProfileEnabled() { + return (mWorkProfileUserHandle != null) + && !isQuietModeEnabled() + && isWorkProfileUserUnlocked(); + } + + private BroadcastReceiver createWorkProfileStateReceiver() { + return new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (!TextUtils.equals(action, Intent.ACTION_USER_UNLOCKED) + && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE) + && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_AVAILABLE)) { + return; + } + + if (intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1) + != mWorkProfileUserHandle.getIdentifier()) { + return; + } + + if (isWorkProfileEnabled()) { + if (mWorkProfileHasBeenEnabled) { + return; + } + mWorkProfileHasBeenEnabled = true; + mIsWaitingToEnableWorkProfile = false; + } else { + // Must be an UNAVAILABLE broadcast, so we watch for the next availability. + // TODO: confirm the above reasoning (& handling of "UNAVAILABLE" in general). + mWorkProfileHasBeenEnabled = false; + } + + mOnWorkProfileStateUpdated.run(); + } + }; + } +} diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java index b7c89907..0333039b 100644 --- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java @@ -26,11 +26,10 @@ import android.content.Context; import android.os.UserHandle; import android.stats.devicepolicy.nano.DevicePolicyEnums; -import com.android.internal.R; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; +import com.android.internal.R; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when @@ -39,19 +38,19 @@ import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeMana public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { private final UserHandle mWorkProfileUserHandle; - private final QuietModeManager mQuietModeManager; + private final WorkProfileAvailabilityManager mWorkProfileAvailability; private final String mMetricsCategory; private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; private final Context mContext; public WorkProfilePausedEmptyStateProvider(@NonNull Context context, @Nullable UserHandle workProfileUserHandle, - @NonNull QuietModeManager quietModeManager, + @NonNull WorkProfileAvailabilityManager workProfileAvailability, @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, @NonNull String metricsCategory) { mContext = context; mWorkProfileUserHandle = workProfileUserHandle; - mQuietModeManager = quietModeManager; + mWorkProfileAvailability = workProfileAvailability; mMetricsCategory = metricsCategory; mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; } @@ -60,7 +59,7 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { @Override public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) - || !mQuietModeManager.isQuietModeEnabled(mWorkProfileUserHandle) + || !mWorkProfileAvailability.isQuietModeEnabled() || resolverListAdapter.getCount() == 0) { return null; } @@ -74,7 +73,7 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { if (mOnSwitchOnWorkSelectedListener != null) { mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); } - mQuietModeManager.requestQuietModeEnabled(false, mWorkProfileUserHandle); + mWorkProfileAvailability.requestQuietModeEnabled(false); }, mMetricsCategory); } diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index db5ae0b4..29be6dc6 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -27,7 +27,6 @@ import android.content.pm.ResolveInfo; import android.os.Bundle; import android.os.UserHandle; -import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.TargetPresentationGetter; import java.util.ArrayList; @@ -97,25 +96,22 @@ public class DisplayResolveInfo implements TargetInfo { final ActivityInfo ai = mResolveInfo.activityInfo; mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; - final Intent intent = new Intent(resolvedIntent); - intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT - | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); - intent.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name)); - mResolvedIntent = intent; + mResolvedIntent = createResolvedIntent(resolvedIntent, ai); } private DisplayResolveInfo( DisplayResolveInfo other, - Intent fillInIntent, - int flags, + @Nullable Intent baseIntentToSend, TargetPresentationGetter presentationGetter) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; mIsSuspended = other.mIsSuspended; mDisplayLabel = other.mDisplayLabel; mExtendedInfo = other.mExtendedInfo; - mResolvedIntent = new Intent(other.mResolvedIntent); - mResolvedIntent.fillIn(fillInIntent, flags); + + mResolvedIntent = createResolvedIntent( + baseIntentToSend == null ? other.mResolvedIntent : baseIntentToSend, + mResolveInfo.activityInfo); mPresentationGetter = presentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); @@ -133,6 +129,14 @@ public class DisplayResolveInfo implements TargetInfo { mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } + private static Intent createResolvedIntent(Intent resolvedIntent, ActivityInfo ai) { + final Intent result = new Intent(resolvedIntent); + result.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT + | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); + result.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name)); + return result; + } + @Override public final boolean isDisplayResolveInfo() { return true; @@ -168,8 +172,21 @@ public class DisplayResolveInfo implements TargetInfo { } @Override - public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return new DisplayResolveInfo(this, fillInIntent, flags, mPresentationGetter); + @Nullable + public DisplayResolveInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) { + Intent matchingBase = + getAllSourceIntents() + .stream() + .filter(i -> i.filterEquals(proposedRefinement)) + .findFirst() + .orElse(null); + if (matchingBase == null) { + return null; + } + + Intent merged = new Intent(matchingBase); + merged.fillIn(proposedRefinement, 0); + return new DisplayResolveInfo(this, merged, mPresentationGetter); } @Override @@ -201,13 +218,7 @@ public class DisplayResolveInfo implements TargetInfo { } @Override - public boolean start(Activity activity, Bundle options) { - activity.startActivity(mResolvedIntent, options); - return true; - } - - @Override - public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + public boolean startAsCaller(Activity activity, Bundle options, int userId) { TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, userId); activity.startActivityAsCaller(mResolvedIntent, options, false, userId); return true; @@ -216,10 +227,21 @@ public class DisplayResolveInfo implements TargetInfo { @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier()); + // TODO: is this equivalent to `startActivityAsCaller` with `ignoreTargetSecurity=true`? If + // so, we can consolidate on the one API method to show that this flag is the only + // distinction between `startAsCaller` and `startAsUser`. We can even bake that flag into + // the `TargetActivityStarter` upfront since it just reflects our "safe forwarding mode" -- + // which is constant for the duration of our lifecycle, leaving clients no other + // responsibilities in this logic. activity.startActivityAsUser(mResolvedIntent, options, user); return false; } + @Override + public Intent getTargetIntent() { + return mResolvedIntent; + } + public boolean isSuspended() { return mIsSuspended; } diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java new file mode 100644 index 00000000..2d9683e1 --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java @@ -0,0 +1,633 @@ +/* + * 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.chooser; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.Activity; +import android.app.prediction.AppTarget; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.os.Bundle; +import android.os.UserHandle; +import android.util.HashedStringCache; + +import androidx.annotation.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.List; + +/** + * An implementation of {@link TargetInfo} with immutable data. Any modifications must be made by + * creating a new instance (e.g., via {@link ImmutableTargetInfo#toBuilder()}). + */ +public final class ImmutableTargetInfo implements TargetInfo { + private static final String TAG = "TargetInfo"; + + /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics()}. */ + public interface TargetHashProvider { + /** Request a hash for the specified {@code target}. */ + HashedStringCache.HashResult getHashedTargetIdForMetrics( + TargetInfo target, Context context); + } + + /** Delegate interface to request that the target be launched by a particular API. */ + public interface TargetActivityStarter { + /** + * Request that the delegate use the {@link Activity#startAsCaller()} API to launch the + * specified {@code target}. + * + * @return true if the target was launched successfully. + */ + boolean startAsCaller(TargetInfo target, Activity activity, Bundle options, int userId); + + /** + * Request that the delegate use the {@link Activity#startAsUser()} API to launch the + * specified {@code target}. + * + * @return true if the target was launched successfully. + */ + boolean startAsUser(TargetInfo target, Activity activity, Bundle options, UserHandle user); + } + + enum LegacyTargetType { + NOT_LEGACY_TARGET, + EMPTY_TARGET_INFO, + PLACEHOLDER_TARGET_INFO, + SELECTABLE_TARGET_INFO, + DISPLAY_RESOLVE_INFO, + MULTI_DISPLAY_RESOLVE_INFO + }; + + /** Builder API to construct {@code ImmutableTargetInfo} instances. */ + public static class Builder { + @Nullable + private ComponentName mResolvedComponentName; + + @Nullable + private Intent mResolvedIntent; + + @Nullable + private Intent mBaseIntentToSend; + + @Nullable + private Intent mTargetIntent; + + @Nullable + private ComponentName mChooserTargetComponentName; + + @Nullable + private ShortcutInfo mDirectShareShortcutInfo; + + @Nullable + private AppTarget mDirectShareAppTarget; + + @Nullable + private DisplayResolveInfo mDisplayResolveInfo; + + @Nullable + private TargetHashProvider mHashProvider; + + @Nullable + private Intent mReferrerFillInIntent; + + @Nullable + private TargetActivityStarter mActivityStarter; + + @Nullable + private ResolveInfo mResolveInfo; + + @Nullable + private CharSequence mDisplayLabel; + + @Nullable + private CharSequence mExtendedInfo; + + @Nullable + private IconHolder mDisplayIconHolder; + + private boolean mIsSuspended; + private boolean mIsPinned; + private float mModifiedScore = -0.1f; + private LegacyTargetType mLegacyType = LegacyTargetType.NOT_LEGACY_TARGET; + + private ImmutableList<Intent> mAlternateSourceIntents = ImmutableList.of(); + private ImmutableList<DisplayResolveInfo> mAllDisplayTargets = ImmutableList.of(); + + /** + * Configure an {@link Intent} to be built in to the output target as the resolution for the + * requested target data. + */ + public Builder setResolvedIntent(Intent resolvedIntent) { + mResolvedIntent = resolvedIntent; + return this; + } + + /** + * Configure an {@link Intent} to be built in to the output target as the "base intent to + * send," which may be a refinement of any of our source targets. This is private because + * it's only used internally by {@link #tryToCloneWithAppliedRefinement()}; if it's ever + * expanded, the builder should probably be responsible for enforcing the refinement check. + */ + private Builder setBaseIntentToSend(Intent baseIntent) { + mBaseIntentToSend = baseIntent; + return this; + } + + /** + * Configure an {@link Intent} to be built in to the output as the "target intent." + */ + public Builder setTargetIntent(Intent targetIntent) { + mTargetIntent = targetIntent; + return this; + } + + /** + * Configure a fill-in intent provided by the referrer to be used in populating the launch + * intent if the output target is ever selected. + * + * @see android.content.Intent#fillIn(Intent, int) + */ + public Builder setReferrerFillInIntent(@Nullable Intent referrerFillInIntent) { + mReferrerFillInIntent = referrerFillInIntent; + return this; + } + + /** + * Configure a {@link ComponentName} to be built in to the output target, as the real + * component we were able to resolve on this device given the available target data. + */ + public Builder setResolvedComponentName(@Nullable ComponentName resolvedComponentName) { + mResolvedComponentName = resolvedComponentName; + return this; + } + + /** + * Configure a {@link ComponentName} to be built in to the output target, as the component + * supposedly associated with a {@link ChooserTarget} from which the builder data is being + * derived. + */ + public Builder setChooserTargetComponentName(@Nullable ComponentName componentName) { + mChooserTargetComponentName = componentName; + return this; + } + + /** Configure the {@link TargetActivityStarter} to be built in to the output target. */ + public Builder setActivityStarter(TargetActivityStarter activityStarter) { + mActivityStarter = activityStarter; + return this; + } + + /** Configure the {@link ResolveInfo} to be built in to the output target. */ + public Builder setResolveInfo(ResolveInfo resolveInfo) { + mResolveInfo = resolveInfo; + return this; + } + + /** Configure the display label to be built in to the output target. */ + public Builder setDisplayLabel(CharSequence displayLabel) { + mDisplayLabel = displayLabel; + return this; + } + + /** Configure the extended info to be built in to the output target. */ + public Builder setExtendedInfo(CharSequence extendedInfo) { + mExtendedInfo = extendedInfo; + return this; + } + + /** Configure the {@link IconHolder} to be built in to the output target. */ + public Builder setDisplayIconHolder(IconHolder displayIconHolder) { + mDisplayIconHolder = displayIconHolder; + return this; + } + + /** Configure the list of alternate source intents we could resolve for this target. */ + public Builder setAlternateSourceIntents(List<Intent> sourceIntents) { + mAlternateSourceIntents = immutableCopyOrEmpty(sourceIntents); + return this; + } + + /** + * Configure the full list of source intents we could resolve for this target. This is + * effectively the same as calling {@link #setResolvedIntent()} with the first element of + * the list, and {@link #setAlternateSourceIntents()} with the remainder (or clearing those + * fields on the builder if there are no corresponding elements in the list). + */ + public Builder setAllSourceIntents(List<Intent> sourceIntents) { + if ((sourceIntents == null) || sourceIntents.isEmpty()) { + setResolvedIntent(null); + setAlternateSourceIntents(null); + return this; + } + + setResolvedIntent(sourceIntents.get(0)); + setAlternateSourceIntents(sourceIntents.subList(1, sourceIntents.size())); + return this; + } + + /** Configure the list of display targets to be built in to the output target. */ + public Builder setAllDisplayTargets(List<DisplayResolveInfo> targets) { + mAllDisplayTargets = immutableCopyOrEmpty(targets); + return this; + } + + /** Configure the is-suspended status to be built in to the output target. */ + public Builder setIsSuspended(boolean isSuspended) { + mIsSuspended = isSuspended; + return this; + } + + /** Configure the is-pinned status to be built in to the output target. */ + public Builder setIsPinned(boolean isPinned) { + mIsPinned = isPinned; + return this; + } + + /** Configure the modified score to be built in to the output target. */ + public Builder setModifiedScore(float modifiedScore) { + mModifiedScore = modifiedScore; + return this; + } + + /** Configure the {@link ShortcutInfo} to be built in to the output target. */ + public Builder setDirectShareShortcutInfo(@Nullable ShortcutInfo shortcutInfo) { + mDirectShareShortcutInfo = shortcutInfo; + return this; + } + + /** Configure the {@link AppTarget} to be built in to the output target. */ + public Builder setDirectShareAppTarget(@Nullable AppTarget appTarget) { + mDirectShareAppTarget = appTarget; + return this; + } + + /** Configure the {@link DisplayResolveInfo} to be built in to the output target. */ + public Builder setDisplayResolveInfo(@Nullable DisplayResolveInfo displayResolveInfo) { + mDisplayResolveInfo = displayResolveInfo; + return this; + } + + /** Configure the {@link TargetHashProvider} to be built in to the output target. */ + public Builder setHashProvider(@Nullable TargetHashProvider hashProvider) { + mHashProvider = hashProvider; + return this; + } + + Builder setLegacyType(@NonNull LegacyTargetType legacyType) { + mLegacyType = legacyType; + return this; + } + + /** Construct an {@code ImmutableTargetInfo} with the current builder data. */ + public ImmutableTargetInfo build() { + List<Intent> sourceIntents = new ArrayList<>(); + if (mResolvedIntent != null) { + sourceIntents.add(mResolvedIntent); + } + if (mAlternateSourceIntents != null) { + sourceIntents.addAll(mAlternateSourceIntents); + } + + Intent baseIntentToSend = mBaseIntentToSend; + if ((baseIntentToSend == null) && !sourceIntents.isEmpty()) { + baseIntentToSend = sourceIntents.get(0); + } + if (baseIntentToSend != null) { + baseIntentToSend = new Intent(baseIntentToSend); + if (mReferrerFillInIntent != null) { + baseIntentToSend.fillIn(mReferrerFillInIntent, 0); + } + } + + return new ImmutableTargetInfo( + baseIntentToSend, + ImmutableList.copyOf(sourceIntents), + mTargetIntent, + mReferrerFillInIntent, + mResolvedComponentName, + mChooserTargetComponentName, + mActivityStarter, + mResolveInfo, + mDisplayLabel, + mExtendedInfo, + mDisplayIconHolder, + mAllDisplayTargets, + mIsSuspended, + mIsPinned, + mModifiedScore, + mDirectShareShortcutInfo, + mDirectShareAppTarget, + mDisplayResolveInfo, + mHashProvider, + mLegacyType); + } + } + + @Nullable + private final Intent mReferrerFillInIntent; + + @Nullable + private final ComponentName mResolvedComponentName; + + @Nullable + private final ComponentName mChooserTargetComponentName; + + @Nullable + private final ShortcutInfo mDirectShareShortcutInfo; + + @Nullable + private final AppTarget mDirectShareAppTarget; + + @Nullable + private final DisplayResolveInfo mDisplayResolveInfo; + + @Nullable + private final TargetHashProvider mHashProvider; + + private final Intent mBaseIntentToSend; + private final ImmutableList<Intent> mSourceIntents; + private final Intent mTargetIntent; + private final TargetActivityStarter mActivityStarter; + private final ResolveInfo mResolveInfo; + private final CharSequence mDisplayLabel; + private final CharSequence mExtendedInfo; + private final IconHolder mDisplayIconHolder; + private final ImmutableList<DisplayResolveInfo> mAllDisplayTargets; + private final boolean mIsSuspended; + private final boolean mIsPinned; + private final float mModifiedScore; + private final LegacyTargetType mLegacyType; + + /** Construct a {@link Builder}. */ + public static Builder newBuilder() { + return new Builder(); + } + + /** Construct a {@link Builder} pre-initialized to match this target. */ + public Builder toBuilder() { + return newBuilder() + .setBaseIntentToSend(getBaseIntentToSend()) + .setResolvedIntent(getResolvedIntent()) + .setTargetIntent(getTargetIntent()) + .setReferrerFillInIntent(getReferrerFillInIntent()) + .setResolvedComponentName(getResolvedComponentName()) + .setChooserTargetComponentName(getChooserTargetComponentName()) + .setActivityStarter(mActivityStarter) + .setResolveInfo(getResolveInfo()) + .setDisplayLabel(getDisplayLabel()) + .setExtendedInfo(getExtendedInfo()) + .setDisplayIconHolder(getDisplayIconHolder()) + .setAllSourceIntents(getAllSourceIntents()) + .setAllDisplayTargets(getAllDisplayTargets()) + .setIsSuspended(isSuspended()) + .setIsPinned(isPinned()) + .setModifiedScore(getModifiedScore()) + .setDirectShareShortcutInfo(getDirectShareShortcutInfo()) + .setDirectShareAppTarget(getDirectShareAppTarget()) + .setDisplayResolveInfo(getDisplayResolveInfo()) + .setHashProvider(getHashProvider()) + .setLegacyType(mLegacyType); + } + + @VisibleForTesting + Intent getBaseIntentToSend() { + return mBaseIntentToSend; + } + + @Override + @Nullable + public ImmutableTargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) { + Intent matchingBase = + getAllSourceIntents() + .stream() + .filter(i -> i.filterEquals(proposedRefinement)) + .findFirst() + .orElse(null); + if (matchingBase == null) { + return null; + } + + Intent merged = new Intent(matchingBase); + merged.fillIn(proposedRefinement, 0); + return toBuilder().setBaseIntentToSend(merged).build(); + } + + @Override + public Intent getResolvedIntent() { + return (mSourceIntents.isEmpty() ? null : mSourceIntents.get(0)); + } + + @Override + public Intent getTargetIntent() { + return mTargetIntent; + } + + @Nullable + public Intent getReferrerFillInIntent() { + return mReferrerFillInIntent; + } + + @Override + @Nullable + public ComponentName getResolvedComponentName() { + return mResolvedComponentName; + } + + @Override + @Nullable + public ComponentName getChooserTargetComponentName() { + return mChooserTargetComponentName; + } + + @Override + public boolean startAsCaller(Activity activity, Bundle options, int userId) { + // TODO: make sure that the component name is set in all cases + return mActivityStarter.startAsCaller(this, activity, options, userId); + } + + @Override + public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { + // TODO: make sure that the component name is set in all cases + return mActivityStarter.startAsUser(this, activity, options, user); + } + + @Override + public ResolveInfo getResolveInfo() { + return mResolveInfo; + } + + @Override + public CharSequence getDisplayLabel() { + return mDisplayLabel; + } + + @Override + public CharSequence getExtendedInfo() { + return mExtendedInfo; + } + + @Override + public IconHolder getDisplayIconHolder() { + return mDisplayIconHolder; + } + + @Override + public List<Intent> getAllSourceIntents() { + return mSourceIntents; + } + + @Override + public ArrayList<DisplayResolveInfo> getAllDisplayTargets() { + ArrayList<DisplayResolveInfo> targets = new ArrayList<>(); + targets.addAll(mAllDisplayTargets); + return targets; + } + + @Override + public boolean isSuspended() { + return mIsSuspended; + } + + @Override + public boolean isPinned() { + return mIsPinned; + } + + @Override + public float getModifiedScore() { + return mModifiedScore; + } + + @Override + @Nullable + public ShortcutInfo getDirectShareShortcutInfo() { + return mDirectShareShortcutInfo; + } + + @Override + @Nullable + public AppTarget getDirectShareAppTarget() { + return mDirectShareAppTarget; + } + + @Override + @Nullable + public DisplayResolveInfo getDisplayResolveInfo() { + return mDisplayResolveInfo; + } + + @Override + public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) { + return (mHashProvider == null) + ? null : mHashProvider.getHashedTargetIdForMetrics(this, context); + } + + @VisibleForTesting + @Nullable + TargetHashProvider getHashProvider() { + return mHashProvider; + } + + @Override + public boolean isEmptyTargetInfo() { + return mLegacyType == LegacyTargetType.EMPTY_TARGET_INFO; + } + + @Override + public boolean isPlaceHolderTargetInfo() { + return mLegacyType == LegacyTargetType.PLACEHOLDER_TARGET_INFO; + } + + @Override + public boolean isNotSelectableTargetInfo() { + return isEmptyTargetInfo() || isPlaceHolderTargetInfo(); + } + + @Override + public boolean isSelectableTargetInfo() { + return mLegacyType == LegacyTargetType.SELECTABLE_TARGET_INFO; + } + + @Override + public boolean isChooserTargetInfo() { + return isNotSelectableTargetInfo() || isSelectableTargetInfo(); + } + + @Override + public boolean isMultiDisplayResolveInfo() { + return mLegacyType == LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO; + } + + @Override + public boolean isDisplayResolveInfo() { + return (mLegacyType == LegacyTargetType.DISPLAY_RESOLVE_INFO) + || isMultiDisplayResolveInfo(); + } + + private ImmutableTargetInfo( + Intent baseIntentToSend, + ImmutableList<Intent> sourceIntents, + Intent targetIntent, + @Nullable Intent referrerFillInIntent, + @Nullable ComponentName resolvedComponentName, + @Nullable ComponentName chooserTargetComponentName, + TargetActivityStarter activityStarter, + ResolveInfo resolveInfo, + CharSequence displayLabel, + CharSequence extendedInfo, + IconHolder iconHolder, + ImmutableList<DisplayResolveInfo> allDisplayTargets, + boolean isSuspended, + boolean isPinned, + float modifiedScore, + @Nullable ShortcutInfo directShareShortcutInfo, + @Nullable AppTarget directShareAppTarget, + @Nullable DisplayResolveInfo displayResolveInfo, + @Nullable TargetHashProvider hashProvider, + LegacyTargetType legacyType) { + mBaseIntentToSend = baseIntentToSend; + mSourceIntents = sourceIntents; + mTargetIntent = targetIntent; + mReferrerFillInIntent = referrerFillInIntent; + mResolvedComponentName = resolvedComponentName; + mChooserTargetComponentName = chooserTargetComponentName; + mActivityStarter = activityStarter; + mResolveInfo = resolveInfo; + mDisplayLabel = displayLabel; + mExtendedInfo = extendedInfo; + mDisplayIconHolder = iconHolder; + mAllDisplayTargets = allDisplayTargets; + mIsSuspended = isSuspended; + mIsPinned = isPinned; + mModifiedScore = modifiedScore; + mDirectShareShortcutInfo = directShareShortcutInfo; + mDirectShareAppTarget = directShareAppTarget; + mDisplayResolveInfo = displayResolveInfo; + mHashProvider = hashProvider; + mLegacyType = legacyType; + } + + private static <E> ImmutableList<E> immutableCopyOrEmpty(@Nullable List<E> source) { + return (source == null) ? ImmutableList.of() : ImmutableList.copyOf(source); + } +} diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index 29f00a35..b97e6b45 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -17,12 +17,14 @@ package com.android.intentresolver.chooser; import android.app.Activity; +import android.content.Intent; import android.os.Bundle; import android.os.UserHandle; -import com.android.intentresolver.ResolverActivity; +import androidx.annotation.Nullable; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -30,7 +32,7 @@ import java.util.List; */ public class MultiDisplayResolveInfo extends DisplayResolveInfo { - ArrayList<DisplayResolveInfo> mTargetInfos = new ArrayList<>(); + final ArrayList<DisplayResolveInfo> mTargetInfos; // Index of selected target private int mSelected = -1; @@ -66,8 +68,9 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { /** * List of all {@link DisplayResolveInfo}s included in this target. - * TODO: provide as a generic {@code List<DisplayResolveInfo>} once {@link ChooserActivity} - * stops requiring the signature to match that of the other "lists" it builds up. + * TODO: provide as a generic {@code List<DisplayResolveInfo>} once + * {@link com.android.intentresolver.ChooserActivity} stops requiring the signature to match + * that of the other "lists" it builds up. */ @Override public ArrayList<DisplayResolveInfo> getAllDisplayTargets() { @@ -93,12 +96,27 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { } @Override - public boolean start(Activity activity, Bundle options) { - return mTargetInfos.get(mSelected).start(activity, options); + @Nullable + public MultiDisplayResolveInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) { + final int size = mTargetInfos.size(); + ArrayList<DisplayResolveInfo> targetInfos = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + DisplayResolveInfo target = mTargetInfos.get(i); + DisplayResolveInfo targetClone = (i == mSelected) + ? target.tryToCloneWithAppliedRefinement(proposedRefinement) + : new DisplayResolveInfo(target); + if (targetClone == null) { + return null; + } + targetInfos.add(targetClone); + } + MultiDisplayResolveInfo clone = new MultiDisplayResolveInfo(targetInfos); + clone.mSelected = mSelected; + return clone; } @Override - public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + public boolean startAsCaller(Activity activity, Bundle options, int userId) { return mTargetInfos.get(mSelected).startAsCaller(activity, options, userId); } @@ -106,4 +124,16 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return mTargetInfos.get(mSelected).startAsUser(activity, options, user); } + + @Override + public Intent getTargetIntent() { + return mTargetInfos.get(mSelected).getTargetIntent(); + } + + @Override + public List<Intent> getAllSourceIntents() { + return hasSelected() + ? mTargetInfos.get(mSelected).getAllSourceIntents() + : Collections.emptyList(); + } } diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index d6333374..6444e13b 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -16,34 +16,30 @@ package com.android.intentresolver.chooser; +import android.annotation.Nullable; import android.app.Activity; -import android.content.ComponentName; import android.content.Context; -import android.content.Intent; -import android.content.pm.ResolveInfo; import android.graphics.drawable.AnimatedVectorDrawable; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; import com.android.intentresolver.R; -import com.android.intentresolver.ResolverActivity; -import java.util.List; +import java.util.function.Supplier; /** * Distinguish between targets that selectable by the user, vs those that are * placeholders for the system while information is loading in an async manner. */ -public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { +public final class NotSelectableTargetInfo { /** Create a non-selectable {@link TargetInfo} with no content. */ public static TargetInfo newEmptyTargetInfo() { - return new NotSelectableTargetInfo() { - @Override - public boolean isEmptyTargetInfo() { - return true; - } - }; + return ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO) + .setDisplayIconHolder(makeReadOnlyIconHolder(() -> null)) + .setActivityStarter(makeNoOpActivityStarter()) + .build(); } /** @@ -51,102 +47,51 @@ public abstract class NotSelectableTargetInfo extends ChooserTargetInfo { * unless/until it can be replaced by the result of a pending asynchronous load. */ public static TargetInfo newPlaceHolderTargetInfo(Context context) { - return new NotSelectableTargetInfo() { - @Override - public boolean isPlaceHolderTargetInfo() { - return true; - } - - @Override - public IconHolder getDisplayIconHolder() { - return new IconHolder() { - @Override - public Drawable getDisplayIcon() { - AnimatedVectorDrawable avd = (AnimatedVectorDrawable) - context.getDrawable( - R.drawable.chooser_direct_share_icon_placeholder); - avd.start(); // Start animation after generation. - return avd; - } - - @Override - public void setDisplayIcon(Drawable icon) {} - }; - } - - @Override - public boolean hasDisplayIcon() { - return true; - } - }; - } - - public final boolean isNotSelectableTargetInfo() { - return true; - } - - public Intent getResolvedIntent() { - return null; - } - - public ComponentName getResolvedComponentName() { - return null; - } - - public boolean start(Activity activity, Bundle options) { - return false; - } - - public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { - return false; - } - - public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { - return false; - } - - public ResolveInfo getResolveInfo() { - return null; - } - - public CharSequence getDisplayLabel() { - return null; - } - - public CharSequence getExtendedInfo() { - return null; - } - - public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return null; + return ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO) + .setDisplayIconHolder( + makeReadOnlyIconHolder(() -> makeStartedPlaceholderDrawable(context))) + .setActivityStarter(makeNoOpActivityStarter()) + .build(); } - public List<Intent> getAllSourceIntents() { - return null; + private static Drawable makeStartedPlaceholderDrawable(Context context) { + AnimatedVectorDrawable avd = (AnimatedVectorDrawable) context.getDrawable( + R.drawable.chooser_direct_share_icon_placeholder); + avd.start(); // Start animation after generation. + return avd; } - public float getModifiedScore() { - return -0.1f; - } - - public boolean isSuspended() { - return false; - } + private static ImmutableTargetInfo.IconHolder makeReadOnlyIconHolder( + Supplier</* @Nullable */ Drawable> iconProvider) { + return new ImmutableTargetInfo.IconHolder() { + @Override + @Nullable + public Drawable getDisplayIcon() { + return iconProvider.get(); + } - public boolean isPinned() { - return false; + @Override + public void setDisplayIcon(Drawable icon) {} + }; } - @Override - public IconHolder getDisplayIconHolder() { - return new IconHolder() { + private static ImmutableTargetInfo.TargetActivityStarter makeNoOpActivityStarter() { + return new ImmutableTargetInfo.TargetActivityStarter() { @Override - public Drawable getDisplayIcon() { - return null; + public boolean startAsCaller( + TargetInfo target, Activity activity, Bundle options, int userId) { + return false; } @Override - public void setDisplayIcon(Drawable icon) {} + public boolean startAsUser( + TargetInfo target, Activity activity, Bundle options, UserHandle user) { + return false; + } }; } + + // TODO: merge all the APIs up to a single `TargetInfo` class. + private NotSelectableTargetInfo() {} } diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 3ab50175..1fbe2da7 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -33,7 +33,6 @@ import android.text.SpannableStringBuilder; import android.util.HashedStringCache; import android.util.Log; -import com.android.intentresolver.ResolverActivity; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; @@ -79,7 +78,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { private final CharSequence mChooserTargetUnsanitizedTitle; private final Icon mChooserTargetIcon; private final Bundle mChooserTargetIntentExtras; - private final int mFillInFlags; private final boolean mIsPinned; private final float mModifiedScore; private final boolean mIsSuspended; @@ -92,12 +90,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { private final TargetActivityStarter mActivityStarter; /** - * A refinement intent from the caller, if any (see - * {@link Intent#EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER}) - */ - private final Intent mFillInIntent; - - /** * An intent containing referrer URI (see {@link Activity#getReferrer()} (possibly {@code null}) * in its extended data under the key {@link Intent#EXTRA_REFERRER}. */ @@ -160,6 +152,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { sourceInfo, backupResolveInfo, resolvedIntent, + null, chooserTargetComponentName, chooserTargetUnsanitizedTitle, chooserTargetIcon, @@ -167,15 +160,14 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { modifiedScore, shortcutInfo, appTarget, - referrerFillInIntent, - /* fillInIntent = */ null, - /* fillInFlags = */ 0); + referrerFillInIntent); } private SelectableTargetInfo( @Nullable DisplayResolveInfo sourceInfo, @Nullable ResolveInfo backupResolveInfo, Intent resolvedIntent, + @Nullable Intent baseIntentToSend, ComponentName chooserTargetComponentName, CharSequence chooserTargetUnsanitizedTitle, Icon chooserTargetIcon, @@ -183,9 +175,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { float modifiedScore, @Nullable ShortcutInfo shortcutInfo, @Nullable AppTarget appTarget, - Intent referrerFillInIntent, - @Nullable Intent fillInIntent, - int fillInFlags) { + Intent referrerFillInIntent) { mSourceInfo = sourceInfo; mBackupResolveInfo = backupResolveInfo; mResolvedIntent = resolvedIntent; @@ -193,8 +183,6 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mShortcutInfo = shortcutInfo; mAppTarget = appTarget; mReferrerFillInIntent = referrerFillInIntent; - mFillInIntent = fillInIntent; - mFillInFlags = fillInFlags; mChooserTargetComponentName = chooserTargetComponentName; mChooserTargetUnsanitizedTitle = chooserTargetUnsanitizedTitle; mChooserTargetIcon = chooserTargetIcon; @@ -210,9 +198,8 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { mAllSourceIntents = getAllSourceIntents(sourceInfo); mBaseIntentToSend = getBaseIntentToSend( + baseIntentToSend, mResolvedIntent, - mFillInIntent, - mFillInFlags, mReferrerFillInIntent); mHashProvider = context -> { @@ -263,11 +250,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { }; } - private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) { + private SelectableTargetInfo(SelectableTargetInfo other, Intent baseIntentToSend) { this( other.mSourceInfo, other.mBackupResolveInfo, other.mResolvedIntent, + baseIntentToSend, other.mChooserTargetComponentName, other.mChooserTargetUnsanitizedTitle, other.mChooserTargetIcon, @@ -275,14 +263,25 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { other.mModifiedScore, other.mShortcutInfo, other.mAppTarget, - other.mReferrerFillInIntent, - fillInIntent, - flags); + other.mReferrerFillInIntent); } @Override - public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { - return new SelectableTargetInfo(this, fillInIntent, flags); + @Nullable + public TargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement) { + Intent matchingBase = + getAllSourceIntents() + .stream() + .filter(i -> i.filterEquals(proposedRefinement)) + .findFirst() + .orElse(null); + if (matchingBase == null) { + return null; + } + + Intent merged = new Intent(matchingBase); + merged.fillIn(proposedRefinement, 0); + return new SelectableTargetInfo(this, merged); } @Override @@ -332,12 +331,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { } @Override - public boolean start(Activity activity, Bundle options) { - return mActivityStarter.start(activity, options); - } - - @Override - public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + public boolean startAsCaller(Activity activity, Bundle options, int userId) { return mActivityStarter.startAsCaller(activity, options, userId); } @@ -346,6 +340,12 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { return mActivityStarter.startAsUser(activity, options, user); } + @Nullable + @Override + public Intent getTargetIntent() { + return mBaseIntentToSend; + } + @Override public ResolveInfo getResolveInfo() { return mResolveInfo; @@ -418,18 +418,14 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { @Nullable private static Intent getBaseIntentToSend( - @Nullable Intent resolvedIntent, - Intent fillInIntent, - int fillInFlags, + @Nullable Intent providedBase, + @Nullable Intent fallbackBase, Intent referrerFillInIntent) { - Intent result = resolvedIntent; + Intent result = (providedBase != null) ? providedBase : fallbackBase; if (result == null) { Log.e(TAG, "ChooserTargetInfo: no base intent available to send"); } else { result = new Intent(result); - if (fillInIntent != null) { - result.fillIn(fillInIntent, fillInFlags); - } result.fillIn(referrerFillInIntent, 0); } return result; diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 72dd1b0b..2f48704c 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -32,8 +32,6 @@ import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.HashedStringCache; -import com.android.intentresolver.ResolverActivity; - import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -88,6 +86,12 @@ public interface TargetInfo { Intent getResolvedIntent(); /** + * Get the target intent, the one that will be used with one of the <code>start</code> methods. + * @return the intent with target will be launced with. + */ + @Nullable Intent getTargetIntent(); + + /** * Get the resolved component name that represents this target. Note that this may not * be the component that will be directly launched by calling one of the <code>start</code> * methods provided; this is the component that will be credited with the launch. This may be @@ -118,24 +122,15 @@ public interface TargetInfo { } /** - * Start the activity referenced by this target. - * - * @param activity calling Activity performing the launch - * @param options ActivityOptions bundle - * @return true if the start completed successfully - */ - boolean start(Activity activity, Bundle options); - - /** - * Start the activity referenced by this target as if the ResolverActivity's caller - * was performing the start operation. + * Start the activity referenced by this target as if the Activity's caller was performing the + * start operation. * * @param activity calling Activity (actually) performing the launch * @param options ActivityOptions bundle * @param userId userId to start as or {@link UserHandle#USER_NULL} for activity's caller * @return true if the start completed successfully */ - boolean startAsCaller(ResolverActivity activity, Bundle options, int userId); + boolean startAsCaller(Activity activity, Bundle options, int userId); /** * Start the activity referenced by this target as a given user. @@ -187,10 +182,25 @@ public interface TargetInfo { default boolean hasDisplayIcon() { return getDisplayIconHolder().getDisplayIcon() != null; } + /** - * Clone this target with the given fill-in information. + * Attempt to apply a {@code proposedRefinement} that the {@link ChooserRefinementManager} + * received from the caller's refinement flow. This may succeed only if the target has a source + * intent that matches the filtering parameters of the proposed refinement (according to + * {@link Intent#filterEquals()}). Then the first such match is the "base intent," and the + * proposed refinement is merged into that base (via {@link Intent#fillIn()}; this can never + * result in a change to the {@link Intent#filterEquals()} status of the base, but may e.g. add + * new "extras" that weren't previously given in the base intent). + * + * @return a copy of this {@link TargetInfo} where the "base intent to send" is the result of + * merging the refinement into the best-matching source intent, if possible. If there is no + * suitable match for the proposed refinement, or if merging fails for any other reason, this + * returns null. + * + * @see android.content.Intent#fillIn(Intent, int) */ - TargetInfo cloneFilledIn(Intent fillInIntent, int flags); + @Nullable + TargetInfo tryToCloneWithAppliedRefinement(Intent proposedRefinement); /** * @return the list of supported source intents deduped against this single target diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java new file mode 100644 index 00000000..205be444 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -0,0 +1,310 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview; + +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; + +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ContentInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.RemoteException; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ImagePreviewView; +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Collection of helpers for building the content preview UI displayed in + * {@link com.android.intentresolver.ChooserActivity}. + * + * A content preview façade. + */ +public final class ChooserContentPreviewUi { + /** + * Delegate to build the default system action buttons to display in the preview layout, if/when + * they're determined to be appropriate for the particular preview we display. + * TODO: clarify why action buttons are part of preview logic. + */ + public interface ActionFactory { + /** Create an action that copies the share content to the clipboard. */ + ActionRow.Action createCopyButton(); + + /** Create an action that opens the share content in a system-default editor. */ + @Nullable + ActionRow.Action createEditButton(); + + /** Create an "Share to Nearby" action. */ + @Nullable + ActionRow.Action createNearbyButton(); + + /** Create custom actions */ + List<ActionRow.Action> createCustomActions(); + + /** + * Provides a share modification action, if any. + */ + @Nullable + Runnable getModifyShareAction(); + + /** + * <p> + * Creates an exclude-text action that can be called when the user changes shared text + * status in the Media + Text preview. + * </p> + * <p> + * <code>true</code> argument value indicates that the text should be excluded. + * </p> + */ + Consumer<Boolean> getExcludeSharedTextAction(); + } + + /** + * Testing shim to specify whether a given mime type is considered to be an "image." + * + * TODO: move away from {@link ChooserActivityOverrideData} as a model to configure our tests, + * then migrate {@link com.android.intentresolver.ChooserActivity#isImageType(String)} into this + * class. + */ + public interface ImageMimeTypeClassifier { + /** @return whether the specified {@code mimeType} is classified as an "image" type. */ + boolean isImageType(String mimeType); + } + + private final ContentPreviewUi mContentPreviewUi; + + public ChooserContentPreviewUi( + Intent targetIntent, + ContentInterface contentResolver, + ImageMimeTypeClassifier imageClassifier, + ImageLoader imageLoader, + ActionFactory actionFactory, + TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + + mContentPreviewUi = createContentPreview( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionElementStatusCallback, + featureFlagRepository); + if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { + transitionElementStatusCallback.onAllTransitionElementsReady(); + } + } + + private ContentPreviewUi createContentPreview( + Intent targetIntent, + ContentInterface contentResolver, + ImageMimeTypeClassifier imageClassifier, + ImageLoader imageLoader, + ActionFactory actionFactory, + TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + int type = findPreferredContentPreview(targetIntent, contentResolver, imageClassifier); + switch (type) { + case CONTENT_PREVIEW_TEXT: + return createTextPreview( + targetIntent, actionFactory, imageLoader, featureFlagRepository); + + case CONTENT_PREVIEW_FILE: + return new FileContentPreviewUi( + extractContentUris(targetIntent), + actionFactory, + imageLoader, + contentResolver, + featureFlagRepository); + + case CONTENT_PREVIEW_IMAGE: + return createImagePreview( + targetIntent, + actionFactory, + contentResolver, + imageClassifier, + imageLoader, + transitionElementStatusCallback, + featureFlagRepository); + } + + return new NoContextPreviewUi(type); + } + + public int getPreferredContentPreview() { + return mContentPreviewUi.getType(); + } + + /** + * Display a content preview of the specified {@code previewType} to preview the content of the + * specified {@code intent}. + */ + public ViewGroup displayContentPreview( + Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + + return mContentPreviewUi.display(resources, layoutInflater, parent); + } + + /** Determine the most appropriate type of preview to show for the provided {@link Intent}. */ + @ContentPreviewType + private static int findPreferredContentPreview( + Intent targetIntent, + ContentInterface resolver, + ImageMimeTypeClassifier imageClassifier) { + /* In {@link android.content.Intent#getType}, the app may specify a very general mime type + * that broadly covers all data being shared, such as {@literal *}/* when sending an image + * and text. We therefore should inspect each item for the preferred type, in order: IMAGE, + * FILE, TEXT. */ + final String action = targetIntent.getAction(); + final String type = targetIntent.getType(); + final boolean isSend = Intent.ACTION_SEND.equals(action); + final boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action); + + if (!(isSend || isSendMultiple) + || (type != null && ClipDescription.compareMimeTypes(type, "text/*"))) { + return CONTENT_PREVIEW_TEXT; + } + + if (isSend) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + return findPreferredContentPreview(uri, resolver, imageClassifier); + } + + List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (uris == null || uris.isEmpty()) { + return CONTENT_PREVIEW_TEXT; + } + + for (Uri uri : uris) { + // Defaulting to file preview when there are mixed image/file types is + // preferable, as it shows the user the correct number of items being shared + int uriPreviewType = findPreferredContentPreview(uri, resolver, imageClassifier); + if (uriPreviewType == CONTENT_PREVIEW_FILE) { + return CONTENT_PREVIEW_FILE; + } + } + + return CONTENT_PREVIEW_IMAGE; + } + + @ContentPreviewType + private static int findPreferredContentPreview( + Uri uri, ContentInterface resolver, ImageMimeTypeClassifier imageClassifier) { + if (uri == null) { + return CONTENT_PREVIEW_TEXT; + } + + String mimeType = null; + try { + mimeType = resolver.getType(uri); + } catch (RemoteException ignored) { + } + return imageClassifier.isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; + } + + private static TextContentPreviewUi createTextPreview( + Intent targetIntent, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + FeatureFlagRepository featureFlagRepository) { + CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); + String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); + ClipData previewData = targetIntent.getClipData(); + Uri previewThumbnail = null; + if (previewData != null) { + if (previewData.getItemCount() > 0) { + ClipData.Item previewDataItem = previewData.getItemAt(0); + previewThumbnail = previewDataItem.getUri(); + } + } + return new TextContentPreviewUi( + sharingText, + previewTitle, + previewThumbnail, + actionFactory, + imageLoader, + featureFlagRepository); + } + + static ImageContentPreviewUi createImagePreview( + Intent targetIntent, + ChooserContentPreviewUi.ActionFactory actionFactory, + ContentInterface contentResolver, + ChooserContentPreviewUi.ImageMimeTypeClassifier imageClassifier, + ImageLoader imageLoader, + ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); + String action = targetIntent.getAction(); + // TODO: why don't we use image classifier for single-element ACTION_SEND? + final List<Uri> imageUris = Intent.ACTION_SEND.equals(action) + ? extractContentUris(targetIntent) + : extractContentUris(targetIntent) + .stream() + .filter(uri -> { + String type = null; + try { + type = contentResolver.getType(uri); + } catch (RemoteException ignored) { + } + return imageClassifier.isImageType(type); + }) + .collect(Collectors.toList()); + return new ImageContentPreviewUi( + imageUris, + text, + actionFactory, + imageLoader, + transitionElementStatusCallback, + featureFlagRepository); + } + + private static List<Uri> extractContentUris(Intent targetIntent) { + List<Uri> uris = new ArrayList<>(); + if (Intent.ACTION_SEND.equals(targetIntent.getAction())) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + if (ContentPreviewUi.validForContentPreview(uri)) { + uris.add(uri); + } + } else { + List<Uri> receivedUris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (receivedUris != null) { + for (Uri uri : receivedUris) { + if (ContentPreviewUi.validForContentPreview(uri)) { + uris.add(uri); + } + } + } + } + return uris; + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java new file mode 100644 index 00000000..ebab147d --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java @@ -0,0 +1,35 @@ +/* + * 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 static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.annotation.IntDef; + +import java.lang.annotation.Retention; + +@Retention(SOURCE) +@IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE, + ContentPreviewType.CONTENT_PREVIEW_IMAGE, + ContentPreviewType.CONTENT_PREVIEW_TEXT}) +public @interface ContentPreviewType { + // Starting at 1 since 0 is considered "undefined" for some of the database transformations + // of tron logs. + int CONTENT_PREVIEW_IMAGE = 1; + int CONTENT_PREVIEW_FILE = 2; + int CONTENT_PREVIEW_TEXT = 3; +} diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java new file mode 100644 index 00000000..39856e66 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -0,0 +1,130 @@ +/* + * 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 static android.content.ContentProvider.getUserIdFromUri; + +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.UserHandle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.animation.DecelerateInterpolator; + +import androidx.annotation.LayoutRes; + +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; +import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.RoundedRectImageView; + +import java.util.ArrayList; +import java.util.List; + +abstract class ContentPreviewUi { + private static final int IMAGE_FADE_IN_MILLIS = 150; + static final String TAG = "ChooserPreview"; + + @ContentPreviewType + public abstract int getType(); + + public abstract ViewGroup display( + Resources resources, LayoutInflater layoutInflater, ViewGroup parent); + + protected static int getActionRowLayout(FeatureFlagRepository featureFlagRepository) { + return featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS) + ? R.layout.scrollable_chooser_action_row + : R.layout.chooser_action_row; + } + + protected static ActionRow inflateActionRow(ViewGroup parent, @LayoutRes int actionRowLayout) { + final ViewStub stub = parent.findViewById(com.android.intentresolver.R.id.action_row_stub); + if (stub != null) { + stub.setLayoutResource(actionRowLayout); + stub.inflate(); + } + return parent.findViewById(com.android.internal.R.id.chooser_action_row); + } + + protected static List<ActionRow.Action> createActions( + List<ActionRow.Action> systemActions, + List<ActionRow.Action> customActions, + FeatureFlagRepository featureFlagRepository) { + ArrayList<ActionRow.Action> actions = + new ArrayList<>(systemActions.size() + customActions.size()); + actions.addAll(systemActions); + if (featureFlagRepository.isEnabled(Flags.SHARESHEET_CUSTOM_ACTIONS)) { + actions.addAll(customActions); + } + return actions; + } + + /** + * Indicate if the incoming content URI should be allowed. + * + * @param uri the uri to test + * @return true if the URI is allowed for content preview + */ + protected static boolean validForContentPreview(Uri uri) throws SecurityException { + if (uri == null) { + return false; + } + int userId = getUserIdFromUri(uri, UserHandle.USER_CURRENT); + if (userId != UserHandle.USER_CURRENT && userId != UserHandle.myUserId()) { + Log.e(ContentPreviewUi.TAG, "dropped invalid content URI belonging to user " + userId); + return false; + } + return true; + } + + protected static void updateViewWithImage(RoundedRectImageView imageView, Bitmap image) { + if (image == null) { + imageView.setVisibility(View.GONE); + return; + } + imageView.setVisibility(View.VISIBLE); + imageView.setAlpha(0.0f); + imageView.setImageBitmap(image); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); + fadeAnim.start(); + } + + protected static void displayPayloadReselectionAction( + ViewGroup layout, + ChooserContentPreviewUi.ActionFactory actionFactory, + FeatureFlagRepository featureFlagRepository) { + Runnable modifyShareAction = actionFactory.getModifyShareAction(); + if (modifyShareAction != null && layout != null + && featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)) { + View modifyShareView = layout.findViewById(R.id.reselection_action); + if (modifyShareView != null) { + modifyShareView.setVisibility(View.VISIBLE); + modifyShareView.setOnClickListener(view -> modifyShareAction.run()); + } + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java new file mode 100644 index 00000000..7cd71475 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview; + +import android.content.ContentInterface; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.DocumentsContract; +import android.provider.Downloads; +import android.provider.OpenableColumns; +import android.text.TextUtils; +import android.util.Log; +import android.util.PluralsMessageFormatter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.widget.ActionRow; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class FileContentPreviewUi extends ContentPreviewUi { + private static final String PLURALS_COUNT = "count"; + private static final String PLURALS_FILE_NAME = "file_name"; + + private final List<Uri> mUris; + private final ChooserContentPreviewUi.ActionFactory mActionFactory; + private final ImageLoader mImageLoader; + private final ContentInterface mContentResolver; + private final FeatureFlagRepository mFeatureFlagRepository; + + FileContentPreviewUi(List<Uri> uris, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + ContentInterface contentResolver, + FeatureFlagRepository featureFlagRepository) { + mUris = uris; + mActionFactory = actionFactory; + mImageLoader = imageLoader; + mContentResolver = contentResolver; + mFeatureFlagRepository = featureFlagRepository; + } + + @Override + public int getType() { + return ContentPreviewType.CONTENT_PREVIEW_FILE; + } + + @Override + public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = displayInternal(resources, layoutInflater, parent); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + return layout; + } + + private ViewGroup displayInternal( + Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_file, parent, false); + + final int uriCount = mUris.size(); + + if (uriCount == 0) { + contentPreviewLayout.setVisibility(View.GONE); + Log.i(TAG, "Appears to be no uris available in EXTRA_STREAM," + + " removing preview area"); + return contentPreviewLayout; + } + + if (uriCount == 1) { + loadFileUriIntoView(mUris.get(0), contentPreviewLayout, mImageLoader, mContentResolver); + } else { + FileInfo fileInfo = extractFileInfo(mUris.get(0), mContentResolver); + int remUriCount = uriCount - 1; + Map<String, Object> arguments = new HashMap<>(); + arguments.put(PLURALS_COUNT, remUriCount); + arguments.put(PLURALS_FILE_NAME, fileInfo.name); + String fileName = + PluralsMessageFormatter.format(resources, arguments, R.string.file_count); + + TextView fileNameView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileName); + + View thumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.ic_file_copy); + } + + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); + if (actionRow != null) { + actionRow.setActions( + createActions( + createFilePreviewActions(), + mActionFactory.createCustomActions(), + mFeatureFlagRepository)); + } + + return contentPreviewLayout; + } + + private List<ActionRow.Action> createFilePreviewActions() { + List<ActionRow.Action> actions = new ArrayList<>(1); + //TODO(b/120417119): + // add action buttonFactory.createCopyButton() + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + return actions; + } + + private static void loadFileUriIntoView( + final Uri uri, + final View parent, + final ImageLoader imageLoader, + final ContentInterface contentResolver) { + FileInfo fileInfo = extractFileInfo(uri, contentResolver); + + TextView fileNameView = parent.findViewById( + com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileInfo.name); + + if (fileInfo.hasThumbnail) { + imageLoader.loadImage( + uri, + (bitmap) -> updateViewWithImage( + parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail), + bitmap)); + } else { + View thumbnailView = parent.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = parent.findViewById( + com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.chooser_file_generic); + } + } + + private static FileInfo extractFileInfo(Uri uri, ContentInterface resolver) { + String fileName = null; + boolean hasThumbnail = false; + + try (Cursor cursor = queryResolver(resolver, uri)) { + if (cursor != null && cursor.getCount() > 0) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE); + int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS); + + cursor.moveToFirst(); + if (nameIndex != -1) { + fileName = cursor.getString(nameIndex); + } else if (titleIndex != -1) { + fileName = cursor.getString(titleIndex); + } + + if (flagsIndex != -1) { + hasThumbnail = (cursor.getInt(flagsIndex) + & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0; + } + } + } catch (SecurityException | NullPointerException e) { + // The ContentResolver already logs the exception. Log something more informative. + Log.w( + TAG, + "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " + + "desired, consider using Intent#createChooser to launch the ChooserActivity, " + + "and set your Intent's clipData and flags in accordance with that method's " + + "documentation"); + } + + if (TextUtils.isEmpty(fileName)) { + fileName = uri.getPath(); + fileName = fileName == null ? "" : fileName; + int index = fileName.lastIndexOf('/'); + if (index != -1) { + fileName = fileName.substring(index + 1); + } + } + + return new FileInfo(fileName, hasThumbnail); + } + + private static Cursor queryResolver(ContentInterface resolver, Uri uri) { + try { + return resolver.query(uri, null, null, null); + } catch (RemoteException e) { + return null; + } + } + + private static class FileInfo { + public final String name; + public final boolean hasThumbnail; + + FileInfo(String name, boolean hasThumbnail) { + this.name = name; + this.hasThumbnail = hasThumbnail; + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java new file mode 100644 index 00000000..db26ab1b --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ImageContentPreviewUi.java @@ -0,0 +1,179 @@ +/* + * 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 static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; + +import android.content.res.Resources; +import android.net.Uri; +import android.text.TextUtils; +import android.text.util.Linkify; +import android.transition.TransitionManager; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.widget.CheckBox; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.flags.Flags; +import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ImagePreviewView; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +class ImageContentPreviewUi extends ContentPreviewUi { + private final List<Uri> mImageUris; + @Nullable + private final CharSequence mText; + private final ChooserContentPreviewUi.ActionFactory mActionFactory; + private final ImageLoader mImageLoader; + private final ImagePreviewView.TransitionElementStatusCallback mTransitionElementStatusCallback; + private final FeatureFlagRepository mFeatureFlagRepository; + + ImageContentPreviewUi( + List<Uri> imageUris, + @Nullable CharSequence text, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + ImagePreviewView.TransitionElementStatusCallback transitionElementStatusCallback, + FeatureFlagRepository featureFlagRepository) { + mImageUris = imageUris; + mText = text; + mActionFactory = actionFactory; + mImageLoader = imageLoader; + mTransitionElementStatusCallback = transitionElementStatusCallback; + mFeatureFlagRepository = featureFlagRepository; + + mImageLoader.prePopulate(mImageUris); + } + + @Override + public int getType() { + return CONTENT_PREVIEW_IMAGE; + } + + @Override + public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = displayInternal(layoutInflater, parent); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + return layout; + } + + private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_image, parent, false); + ImagePreviewView imagePreview = inflateImagePreviewView(contentPreviewLayout); + + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); + if (actionRow != null) { + actionRow.setActions( + createActions( + createImagePreviewActions(), + mActionFactory.createCustomActions(), + mFeatureFlagRepository)); + } + + if (mImageUris.size() == 0) { + Log.i( + TAG, + "Attempted to display image preview area with zero" + + " available images detected in EXTRA_STREAM list"); + ((View) imagePreview).setVisibility(View.GONE); + mTransitionElementStatusCallback.onAllTransitionElementsReady(); + return contentPreviewLayout; + } + + setTextInImagePreviewVisibility(contentPreviewLayout, mActionFactory); + imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); + imagePreview.setImages(mImageUris, mImageLoader); + + return contentPreviewLayout; + } + + private List<ActionRow.Action> createImagePreviewActions() { + ArrayList<ActionRow.Action> actions = new ArrayList<>(2); + //TODO: add copy action; + ActionRow.Action action = mActionFactory.createNearbyButton(); + if (action != null) { + actions.add(action); + } + action = mActionFactory.createEditButton(); + if (action != null) { + actions.add(action); + } + return actions; + } + + private ImagePreviewView inflateImagePreviewView(ViewGroup previewLayout) { + ViewStub stub = previewLayout.findViewById(R.id.image_preview_stub); + if (stub != null) { + int layoutId = + mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW) + ? R.layout.scrollable_image_preview_view + : R.layout.chooser_image_preview_view; + stub.setLayoutResource(layoutId); + stub.inflate(); + } + return previewLayout.findViewById( + com.android.internal.R.id.content_preview_image_area); + } + + private void setTextInImagePreviewVisibility( + ViewGroup contentPreview, ChooserContentPreviewUi.ActionFactory actionFactory) { + int visibility = mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW) + && !TextUtils.isEmpty(mText) + ? View.VISIBLE + : View.GONE; + + final TextView textView = contentPreview + .requireViewById(com.android.internal.R.id.content_preview_text); + CheckBox actionView = contentPreview + .requireViewById(R.id.include_text_action); + textView.setVisibility(visibility); + boolean isLink = visibility == View.VISIBLE && HttpUriMatcher.isHttpUri(mText.toString()); + textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); + textView.setText(mText); + + if (visibility == View.VISIBLE) { + final int[] actionLabels = isLink + ? new int[] { R.string.include_link, R.string.exclude_link } + : new int[] { R.string.include_text, R.string.exclude_text }; + final Consumer<Boolean> shareTextAction = actionFactory.getExcludeSharedTextAction(); + actionView.setChecked(true); + actionView.setText(actionLabels[1]); + shareTextAction.accept(false); + actionView.setOnCheckedChangeListener((view, isChecked) -> { + view.setText(actionLabels[isChecked ? 1 : 0]); + TransitionManager.beginDelayedTransition((ViewGroup) textView.getParent()); + textView.setVisibility(isChecked ? View.VISIBLE : View.GONE); + shareTextAction.accept(!isChecked); + }); + } + actionView.setVisibility(visibility); + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt new file mode 100644 index 00000000..80232537 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt @@ -0,0 +1,27 @@ +/* + * 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("HttpUriMatcher") +package com.android.intentresolver.contentpreview + +import java.net.URI + +internal fun String.isHttpUri() = + kotlin.runCatching { + URI(this).scheme.takeIf { scheme -> + "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 + } + }.getOrNull() != null diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt new file mode 100644 index 00000000..90016932 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt @@ -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. + */ + +package com.android.intentresolver.contentpreview + +import android.content.res.Resources +import android.util.Log +import android.view.LayoutInflater +import android.view.ViewGroup + +internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() { + override fun getType(): Int = type + + override fun display( + resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup? + ): ViewGroup? { + Log.e(TAG, "Unexpected content preview type: $type") + return null + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java new file mode 100644 index 00000000..7901e4cb --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview; + +import android.content.res.Resources; +import android.net.Uri; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ImageLoader; +import com.android.intentresolver.R; +import com.android.intentresolver.flags.FeatureFlagRepository; +import com.android.intentresolver.widget.ActionRow; + +import java.util.ArrayList; +import java.util.List; + +class TextContentPreviewUi extends ContentPreviewUi { + @Nullable + private final CharSequence mSharingText; + @Nullable + private final CharSequence mPreviewTitle; + @Nullable + private final Uri mPreviewThumbnail; + private final ImageLoader mImageLoader; + private final ChooserContentPreviewUi.ActionFactory mActionFactory; + private final FeatureFlagRepository mFeatureFlagRepository; + + TextContentPreviewUi( + @Nullable CharSequence sharingText, + @Nullable CharSequence previewTitle, + @Nullable Uri previewThumbnail, + ChooserContentPreviewUi.ActionFactory actionFactory, + ImageLoader imageLoader, + FeatureFlagRepository featureFlagRepository) { + mSharingText = sharingText; + mPreviewTitle = previewTitle; + mPreviewThumbnail = previewThumbnail; + mImageLoader = imageLoader; + mActionFactory = actionFactory; + mFeatureFlagRepository = featureFlagRepository; + } + + @Override + public int getType() { + return ContentPreviewType.CONTENT_PREVIEW_TEXT; + } + + @Override + public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = displayInternal(layoutInflater, parent); + displayPayloadReselectionAction(layout, mActionFactory, mFeatureFlagRepository); + return layout; + } + + private ViewGroup displayInternal( + LayoutInflater layoutInflater, + ViewGroup parent) { + @LayoutRes int actionRowLayout = getActionRowLayout(mFeatureFlagRepository); + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_text, parent, false); + + final ActionRow actionRow = inflateActionRow(contentPreviewLayout, actionRowLayout); + if (actionRow != null) { + actionRow.setActions( + createActions( + createTextPreviewActions(), + mActionFactory.createCustomActions(), + mFeatureFlagRepository)); + } + + if (mSharingText == null) { + contentPreviewLayout + .findViewById(com.android.internal.R.id.content_preview_text_layout) + .setVisibility(View.GONE); + } else { + TextView textView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_text); + textView.setText(mSharingText); + } + + if (TextUtils.isEmpty(mPreviewTitle)) { + contentPreviewLayout + .findViewById(com.android.internal.R.id.content_preview_title_layout) + .setVisibility(View.GONE); + } else { + TextView previewTitleView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_title); + previewTitleView.setText(mPreviewTitle); + + ImageView previewThumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail); + if (!validForContentPreview(mPreviewThumbnail)) { + previewThumbnailView.setVisibility(View.GONE); + } else { + mImageLoader.loadImage( + mPreviewThumbnail, + (bitmap) -> updateViewWithImage( + contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail), + bitmap)); + } + } + + return contentPreviewLayout; + } + + private List<ActionRow.Action> createTextPreviewActions() { + ArrayList<ActionRow.Action> actions = new ArrayList<>(2); + actions.add(mActionFactory.createCopyButton()); + ActionRow.Action nearbyAction = mActionFactory.createNearbyButton(); + if (nearbyAction != null) { + actions.add(nearbyAction); + } + return actions; + } +} diff --git a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt new file mode 100644 index 00000000..d1494fe7 --- /dev/null +++ b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.flags + +import android.provider.DeviceConfig +import com.android.systemui.flags.ParcelableFlag + +internal class DeviceConfigProxy { + fun isEnabled(flag: ParcelableFlag<Boolean>): Boolean? { + return runCatching { + val hasProperty = DeviceConfig.getProperty(flag.namespace, flag.name) != null + if (hasProperty) { + DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default) + } else { + null + } + }.getOrDefault(null) + } +} diff --git a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt new file mode 100644 index 00000000..5b5d769c --- /dev/null +++ b/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.flags + +import com.android.systemui.flags.ReleasedFlag +import com.android.systemui.flags.UnreleasedFlag + +interface FeatureFlagRepository { + fun isEnabled(flag: UnreleasedFlag): Boolean + fun isEnabled(flag: ReleasedFlag): Boolean +} diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt new file mode 100644 index 00000000..f4dbeddb --- /dev/null +++ b/java/src/com/android/intentresolver/flags/Flags.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.flags + +import com.android.systemui.flags.UnreleasedFlag + +// Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to +// make the flags available in the flag flipper app (see go/sysui-flags). +object Flags { + const val SHARESHEET_CUSTOM_ACTIONS_NAME = "sharesheet_custom_actions" + const val SHARESHEET_RESELECTION_ACTION_NAME = "sharesheet_reselection_action" + const val SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME = "sharesheet_image_text_preview" + const val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME = "sharesheet_scrollable_image_preview" + + // TODO(b/266983432) Tracking Bug + @JvmField + val SHARESHEET_CUSTOM_ACTIONS = unreleasedFlag( + 1501, SHARESHEET_CUSTOM_ACTIONS_NAME, teamfood = true + ) + + // TODO(b/266982749) Tracking Bug + @JvmField + val SHARESHEET_RESELECTION_ACTION = unreleasedFlag( + 1502, SHARESHEET_RESELECTION_ACTION_NAME, teamfood = true + ) + + // TODO(b/266983474) Tracking Bug + @JvmField + val SHARESHEET_IMAGE_AND_TEXT_PREVIEW = unreleasedFlag( + id = 1503, name = SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME, teamfood = true + ) + + // TODO(b/267355521) Tracking Bug + @JvmField + val SHARESHEET_SCROLLABLE_IMAGE_PREVIEW = unreleasedFlag( + 1504, SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME, teamfood = true + ) + + private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = + UnreleasedFlag(id, name, "systemui", teamfood) +} diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index 271c6f98..ea767568 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -30,8 +30,8 @@ import android.os.UserHandle; import android.util.Log; import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import java.text.Collator; import java.util.ArrayList; diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index c6bb2b85..c986ef15 100644 --- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -32,7 +32,7 @@ import android.os.UserHandle; import android.util.Log; import com.android.intentresolver.ChooserActivityLogger; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.ResolvedComponentInfo; import java.util.ArrayList; import java.util.Comparator; diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index 4382f109..0431078c 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -38,7 +38,7 @@ import android.service.resolver.ResolverTarget; import android.util.Log; import com.android.intentresolver.ChooserActivityLogger; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.ResolvedComponentInfo; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java deleted file mode 100644 index 1cfa2c8d..00000000 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java +++ /dev/null @@ -1,426 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.shortcuts; - -import android.app.ActivityManager; -import android.app.prediction.AppPredictor; -import android.app.prediction.AppTarget; -import android.content.ComponentName; -import android.content.Context; -import android.content.IntentFilter; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.PackageManager.ApplicationInfoFlags; -import android.content.pm.PackageManager.NameNotFoundException; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.os.AsyncTask; -import android.os.UserHandle; -import android.os.UserManager; -import android.service.chooser.ChooserTarget; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.MainThread; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.annotation.WorkerThread; - -import com.android.intentresolver.chooser.DisplayResolveInfo; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Executor; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -/** - * Encapsulates shortcuts loading logic from either AppPredictor or ShortcutManager. - * <p> - * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut - * updates. The shortcut loading is triggered by the {@link #queryShortcuts(DisplayResolveInfo[])}, - * the processing will happen on the {@link #mBackgroundExecutor} and the result is delivered - * through the {@link #mCallback} on the {@link #mCallbackExecutor}, the main thread. - * </p> - * <p> - * The current version does not improve on the legacy in a way that it does not guarantee that - * each invocation of the {@link #queryShortcuts(DisplayResolveInfo[])} will be matched by an - * invocation of the callback (there are early terminations of the flow). Also, the fetched - * shortcuts would be matched against the last known input, i.e. two invocations of - * {@link #queryShortcuts(DisplayResolveInfo[])} may result in two callbacks where shortcuts are - * processed against the latest input. - * </p> - */ -public class ShortcutLoader { - private static final String TAG = "ChooserActivity"; - - private static final Request NO_REQUEST = new Request(new DisplayResolveInfo[0]); - - private final Context mContext; - @Nullable - private final AppPredictorProxy mAppPredictor; - private final UserHandle mUserHandle; - @Nullable - private final IntentFilter mTargetIntentFilter; - private final Executor mBackgroundExecutor; - private final Executor mCallbackExecutor; - private final boolean mIsPersonalProfile; - private final ShortcutToChooserTargetConverter mShortcutToChooserTargetConverter = - new ShortcutToChooserTargetConverter(); - private final UserManager mUserManager; - private final AtomicReference<Consumer<Result>> mCallback = new AtomicReference<>(); - private final AtomicReference<Request> mActiveRequest = new AtomicReference<>(NO_REQUEST); - - @Nullable - private final AppPredictor.Callback mAppPredictorCallback; - - @MainThread - public ShortcutLoader( - Context context, - @Nullable AppPredictor appPredictor, - UserHandle userHandle, - @Nullable IntentFilter targetIntentFilter, - Consumer<Result> callback) { - this( - context, - appPredictor == null ? null : new AppPredictorProxy(appPredictor), - userHandle, - userHandle.equals(UserHandle.of(ActivityManager.getCurrentUser())), - targetIntentFilter, - AsyncTask.SERIAL_EXECUTOR, - context.getMainExecutor(), - callback); - } - - @VisibleForTesting - ShortcutLoader( - Context context, - @Nullable AppPredictorProxy appPredictor, - UserHandle userHandle, - boolean isPersonalProfile, - @Nullable IntentFilter targetIntentFilter, - Executor backgroundExecutor, - Executor callbackExecutor, - Consumer<Result> callback) { - mContext = context; - mAppPredictor = appPredictor; - mUserHandle = userHandle; - mTargetIntentFilter = targetIntentFilter; - mBackgroundExecutor = backgroundExecutor; - mCallbackExecutor = callbackExecutor; - mCallback.set(callback); - mIsPersonalProfile = isPersonalProfile; - mUserManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE); - - if (mAppPredictor != null) { - mAppPredictorCallback = createAppPredictorCallback(); - mAppPredictor.registerPredictionUpdates(mCallbackExecutor, mAppPredictorCallback); - } else { - mAppPredictorCallback = null; - } - } - - /** - * Unsubscribe from app predictor if one was provided. - */ - @MainThread - public void destroy() { - if (mCallback.getAndSet(null) != null) { - if (mAppPredictor != null) { - mAppPredictor.unregisterPredictionUpdates(mAppPredictorCallback); - } - } - } - - private boolean isDestroyed() { - return mCallback.get() == null; - } - - /** - * Set new resolved targets. This will trigger shortcut loading. - * @param appTargets a collection of application targets a loaded set of shortcuts will be - * grouped against - */ - @MainThread - public void queryShortcuts(DisplayResolveInfo[] appTargets) { - if (isDestroyed()) { - return; - } - mActiveRequest.set(new Request(appTargets)); - mBackgroundExecutor.execute(this::loadShortcuts); - } - - @WorkerThread - private void loadShortcuts() { - // no need to query direct share for work profile when its locked or disabled - if (!shouldQueryDirectShareTargets()) { - return; - } - Log.d(TAG, "querying direct share targets"); - queryDirectShareTargets(false); - } - - @WorkerThread - private void queryDirectShareTargets(boolean skipAppPredictionService) { - if (isDestroyed()) { - return; - } - if (!skipAppPredictionService && mAppPredictor != null) { - mAppPredictor.requestPredictionUpdate(); - return; - } - // Default to just querying ShortcutManager if AppPredictor not present. - if (mTargetIntentFilter == null) { - return; - } - - Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); - ShortcutManager sm = (ShortcutManager) selectedProfileContext - .getSystemService(Context.SHORTCUT_SERVICE); - List<ShortcutManager.ShareShortcutInfo> shortcuts = - sm.getShareTargets(mTargetIntentFilter); - sendShareShortcutInfoList(shortcuts, false, null); - } - - private AppPredictor.Callback createAppPredictorCallback() { - return appPredictorTargets -> { - if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { - // APS may be disabled, so try querying targets ourselves. - queryDirectShareTargets(true); - return; - } - - final List<ShortcutManager.ShareShortcutInfo> shortcuts = new ArrayList<>(); - List<AppTarget> shortcutResults = new ArrayList<>(); - for (AppTarget appTarget : appPredictorTargets) { - if (appTarget.getShortcutInfo() == null) { - continue; - } - shortcutResults.add(appTarget); - } - appPredictorTargets = shortcutResults; - for (AppTarget appTarget : appPredictorTargets) { - shortcuts.add(new ShortcutManager.ShareShortcutInfo( - appTarget.getShortcutInfo(), - new ComponentName(appTarget.getPackageName(), appTarget.getClassName()))); - } - sendShareShortcutInfoList(shortcuts, true, appPredictorTargets); - }; - } - - @WorkerThread - private void sendShareShortcutInfoList( - List<ShortcutManager.ShareShortcutInfo> shortcuts, - boolean isFromAppPredictor, - @Nullable List<AppTarget> appPredictorTargets) { - if (appPredictorTargets != null && appPredictorTargets.size() != shortcuts.size()) { - throw new RuntimeException("resultList and appTargets must have the same size." - + " resultList.size()=" + shortcuts.size() - + " appTargets.size()=" + appPredictorTargets.size()); - } - Context selectedProfileContext = mContext.createContextAsUser(mUserHandle, 0 /* flags */); - for (int i = shortcuts.size() - 1; i >= 0; i--) { - final String packageName = shortcuts.get(i).getTargetComponent().getPackageName(); - if (!isPackageEnabled(selectedProfileContext, packageName)) { - shortcuts.remove(i); - if (appPredictorTargets != null) { - appPredictorTargets.remove(i); - } - } - } - - HashMap<ChooserTarget, AppTarget> directShareAppTargetCache = new HashMap<>(); - HashMap<ChooserTarget, ShortcutInfo> directShareShortcutInfoCache = new HashMap<>(); - // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path - // for direct share targets. After ShareSheet is refactored we should use the - // ShareShortcutInfos directly. - final DisplayResolveInfo[] appTargets = mActiveRequest.get().appTargets; - List<ShortcutResultInfo> resultRecords = new ArrayList<>(); - for (DisplayResolveInfo displayResolveInfo : appTargets) { - List<ShortcutManager.ShareShortcutInfo> matchingShortcuts = - filterShortcutsByTargetComponentName( - shortcuts, displayResolveInfo.getResolvedComponentName()); - if (matchingShortcuts.isEmpty()) { - continue; - } - - List<ChooserTarget> chooserTargets = mShortcutToChooserTargetConverter - .convertToChooserTarget( - matchingShortcuts, - shortcuts, - appPredictorTargets, - directShareAppTargetCache, - directShareShortcutInfoCache); - - ShortcutResultInfo resultRecord = - new ShortcutResultInfo(displayResolveInfo, chooserTargets); - resultRecords.add(resultRecord); - } - - postReport( - new Result( - isFromAppPredictor, - appTargets, - resultRecords.toArray(new ShortcutResultInfo[0]), - directShareAppTargetCache, - directShareShortcutInfoCache)); - } - - private void postReport(Result result) { - mCallbackExecutor.execute(() -> report(result)); - } - - @MainThread - private void report(Result result) { - Consumer<Result> callback = mCallback.get(); - if (callback != null) { - callback.accept(result); - } - } - - /** - * Returns {@code false} if {@code userHandle} is the work profile and it's either - * in quiet mode or not running. - */ - private boolean shouldQueryDirectShareTargets() { - return mIsPersonalProfile || isProfileActive(); - } - - @VisibleForTesting - protected boolean isProfileActive() { - return mUserManager.isUserRunning(mUserHandle) - && mUserManager.isUserUnlocked(mUserHandle) - && !mUserManager.isQuietModeEnabled(mUserHandle); - } - - private static boolean isPackageEnabled(Context context, String packageName) { - if (TextUtils.isEmpty(packageName)) { - return false; - } - ApplicationInfo appInfo; - try { - appInfo = context.getPackageManager().getApplicationInfo( - packageName, - ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); - } catch (NameNotFoundException e) { - return false; - } - - return appInfo != null && appInfo.enabled - && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0; - } - - private static List<ShortcutManager.ShareShortcutInfo> filterShortcutsByTargetComponentName( - List<ShortcutManager.ShareShortcutInfo> allShortcuts, ComponentName requiredTarget) { - List<ShortcutManager.ShareShortcutInfo> matchingShortcuts = new ArrayList<>(); - for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) { - if (requiredTarget.equals(shortcut.getTargetComponent())) { - matchingShortcuts.add(shortcut); - } - } - return matchingShortcuts; - } - - private static class Request { - public final DisplayResolveInfo[] appTargets; - - Request(DisplayResolveInfo[] targets) { - appTargets = targets; - } - } - - /** - * Resolved shortcuts with corresponding app targets. - */ - public static class Result { - public final boolean isFromAppPredictor; - /** - * Input app targets (see {@link ShortcutLoader#queryShortcuts(DisplayResolveInfo[])} the - * shortcuts were process against. - */ - public final DisplayResolveInfo[] appTargets; - /** - * Shortcuts grouped by app target. - */ - public final ShortcutResultInfo[] shortcutsByApp; - public final Map<ChooserTarget, AppTarget> directShareAppTargetCache; - public final Map<ChooserTarget, ShortcutInfo> directShareShortcutInfoCache; - - @VisibleForTesting - public Result( - boolean isFromAppPredictor, - DisplayResolveInfo[] appTargets, - ShortcutResultInfo[] shortcutsByApp, - Map<ChooserTarget, AppTarget> directShareAppTargetCache, - Map<ChooserTarget, ShortcutInfo> directShareShortcutInfoCache) { - this.isFromAppPredictor = isFromAppPredictor; - this.appTargets = appTargets; - this.shortcutsByApp = shortcutsByApp; - this.directShareAppTargetCache = directShareAppTargetCache; - this.directShareShortcutInfoCache = directShareShortcutInfoCache; - } - } - - /** - * Shortcuts grouped by app. - */ - public static class ShortcutResultInfo { - public final DisplayResolveInfo appTarget; - public final List<ChooserTarget> shortcuts; - - public ShortcutResultInfo(DisplayResolveInfo appTarget, List<ChooserTarget> shortcuts) { - this.appTarget = appTarget; - this.shortcuts = shortcuts; - } - } - - /** - * A wrapper around AppPredictor to facilitate unit-testing. - */ - @VisibleForTesting - public static class AppPredictorProxy { - private final AppPredictor mAppPredictor; - - AppPredictorProxy(AppPredictor appPredictor) { - mAppPredictor = appPredictor; - } - - /** - * {@link AppPredictor#registerPredictionUpdates} - */ - public void registerPredictionUpdates( - Executor callbackExecutor, AppPredictor.Callback callback) { - mAppPredictor.registerPredictionUpdates(callbackExecutor, callback); - } - - /** - * {@link AppPredictor#unregisterPredictionUpdates} - */ - public void unregisterPredictionUpdates(AppPredictor.Callback callback) { - mAppPredictor.unregisterPredictionUpdates(callback); - } - - /** - * {@link AppPredictor#requestPredictionUpdate} - */ - public void requestPredictionUpdate() { - mAppPredictor.requestPredictionUpdate(); - } - } -} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt new file mode 100644 index 00000000..6f7542f1 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -0,0 +1,326 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.shortcuts + +import android.app.ActivityManager +import android.app.prediction.AppPredictor +import android.app.prediction.AppTarget +import android.content.ComponentName +import android.content.Context +import android.content.IntentFilter +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.content.pm.ShortcutManager.ShareShortcutInfo +import android.os.AsyncTask +import android.os.UserHandle +import android.os.UserManager +import android.service.chooser.ChooserTarget +import android.text.TextUtils +import android.util.Log +import androidx.annotation.MainThread +import androidx.annotation.OpenForTesting +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import com.android.intentresolver.chooser.DisplayResolveInfo +import java.lang.RuntimeException +import java.util.ArrayList +import java.util.HashMap +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Consumer + +/** + * Encapsulates shortcuts loading logic from either AppPredictor or ShortcutManager. + * + * + * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut + * updates. The shortcut loading is triggered by the [queryShortcuts], + * the processing will happen on the [backgroundExecutor] and the result is delivered + * through the [callback] on the [callbackExecutor], the main thread. + * + * + * The current version does not improve on the legacy in a way that it does not guarantee that + * each invocation of the [queryShortcuts] will be matched by an + * invocation of the callback (there are early terminations of the flow). Also, the fetched + * shortcuts would be matched against the last known input, i.e. two invocations of + * [queryShortcuts] may result in two callbacks where shortcuts are + * processed against the latest input. + * + */ +@OpenForTesting +open class ShortcutLoader @VisibleForTesting constructor( + private val context: Context, + private val appPredictor: AppPredictorProxy?, + private val userHandle: UserHandle, + private val isPersonalProfile: Boolean, + private val targetIntentFilter: IntentFilter?, + private val backgroundExecutor: Executor, + private val callbackExecutor: Executor, + private val callback: Consumer<Result> +) { + private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() + private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager + private val activeRequest = AtomicReference(NO_REQUEST) + private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) } + private var isDestroyed = false + + @MainThread + constructor( + context: Context, + appPredictor: AppPredictor?, + userHandle: UserHandle, + targetIntentFilter: IntentFilter?, + callback: Consumer<Result> + ) : this( + context, + appPredictor?.let { AppPredictorProxy(it) }, + userHandle, userHandle == UserHandle.of(ActivityManager.getCurrentUser()), + targetIntentFilter, + AsyncTask.SERIAL_EXECUTOR, + context.mainExecutor, + callback + ) + + init { + appPredictor?.registerPredictionUpdates(callbackExecutor, appPredictorCallback) + } + + /** + * Unsubscribe from app predictor if one was provided. + */ + @OpenForTesting + @MainThread + open fun destroy() { + isDestroyed = true + appPredictor?.unregisterPredictionUpdates(appPredictorCallback) + } + + /** + * Set new resolved targets. This will trigger shortcut loading. + * @param appTargets a collection of application targets a loaded set of shortcuts will be + * grouped against + */ + @OpenForTesting + @MainThread + open fun queryShortcuts(appTargets: Array<DisplayResolveInfo>) { + if (isDestroyed) return + activeRequest.set(Request(appTargets)) + backgroundExecutor.execute { loadShortcuts() } + } + + @WorkerThread + private fun loadShortcuts() { + // no need to query direct share for work profile when its locked or disabled + if (!shouldQueryDirectShareTargets()) return + Log.d(TAG, "querying direct share targets") + queryDirectShareTargets(false) + } + + @WorkerThread + private fun queryDirectShareTargets(skipAppPredictionService: Boolean) { + if (!skipAppPredictionService && appPredictor != null) { + appPredictor.requestPredictionUpdate() + return + } + // Default to just querying ShortcutManager if AppPredictor not present. + if (targetIntentFilter == null) return + val shortcuts = queryShortcutManager(targetIntentFilter) + sendShareShortcutInfoList(shortcuts, false, null) + } + + @WorkerThread + private fun queryShortcutManager(targetIntentFilter: IntentFilter): List<ShareShortcutInfo> { + val selectedProfileContext = context.createContextAsUser(userHandle, 0 /* flags */) + val sm = selectedProfileContext + .getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager? + val pm = context.createContextAsUser(userHandle, 0 /* flags */).packageManager + return sm?.getShareTargets(targetIntentFilter) + ?.filter { pm.isPackageEnabled(it.targetComponent.packageName) } + ?: emptyList() + } + + @WorkerThread + private fun onAppPredictorCallback(appPredictorTargets: List<AppTarget>) { + if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { + // APS may be disabled, so try querying targets ourselves. + queryDirectShareTargets(true) + return + } + val pm = context.createContextAsUser(userHandle, 0).packageManager + val pair = appPredictorTargets.toShortcuts(pm) + sendShareShortcutInfoList(pair.shortcuts, true, pair.appTargets) + } + + @WorkerThread + private fun List<AppTarget>.toShortcuts(pm: PackageManager): ShortcutsAppTargetsPair = + fold( + ShortcutsAppTargetsPair(ArrayList(size), ArrayList(size)) + ) { acc, appTarget -> + val shortcutInfo = appTarget.shortcutInfo + val packageName = appTarget.packageName + val className = appTarget.className + if (shortcutInfo != null && className != null && pm.isPackageEnabled(packageName)) { + (acc.shortcuts as ArrayList<ShareShortcutInfo>).add( + ShareShortcutInfo(shortcutInfo, ComponentName(packageName, className)) + ) + (acc.appTargets as ArrayList<AppTarget>).add(appTarget) + } + acc + } + + @WorkerThread + private fun sendShareShortcutInfoList( + shortcuts: List<ShareShortcutInfo>, + isFromAppPredictor: Boolean, + appPredictorTargets: List<AppTarget>? + ) { + if (appPredictorTargets != null && appPredictorTargets.size != shortcuts.size) { + throw RuntimeException( + "resultList and appTargets must have the same size." + + " resultList.size()=" + shortcuts.size + + " appTargets.size()=" + appPredictorTargets.size + ) + } + val directShareAppTargetCache = HashMap<ChooserTarget, AppTarget>() + val directShareShortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>() + // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path + // for direct share targets. After ShareSheet is refactored we should use the + // ShareShortcutInfos directly. + val appTargets = activeRequest.get().appTargets + val resultRecords: MutableList<ShortcutResultInfo> = ArrayList() + for (displayResolveInfo in appTargets) { + val matchingShortcuts = shortcuts.filter { + it.targetComponent == displayResolveInfo.resolvedComponentName + } + if (matchingShortcuts.isEmpty()) continue + val chooserTargets = shortcutToChooserTargetConverter.convertToChooserTarget( + matchingShortcuts, + shortcuts, + appPredictorTargets, + directShareAppTargetCache, + directShareShortcutInfoCache + ) + val resultRecord = ShortcutResultInfo(displayResolveInfo, chooserTargets) + resultRecords.add(resultRecord) + } + postReport( + Result( + isFromAppPredictor, + appTargets, + resultRecords.toTypedArray(), + directShareAppTargetCache, + directShareShortcutInfoCache + ) + ) + } + + private fun postReport(result: Result) = callbackExecutor.execute { report(result) } + + @MainThread + private fun report(result: Result) { + if (isDestroyed) return + callback.accept(result) + } + + /** + * Returns `false` if `userHandle` is the work profile and it's either + * in quiet mode or not running. + */ + private fun shouldQueryDirectShareTargets(): Boolean = isPersonalProfile || isProfileActive + + @get:VisibleForTesting + protected val isProfileActive: Boolean + get() = userManager.isUserRunning(userHandle) + && userManager.isUserUnlocked(userHandle) + && !userManager.isQuietModeEnabled(userHandle) + + private class Request(val appTargets: Array<DisplayResolveInfo>) + + /** + * Resolved shortcuts with corresponding app targets. + */ + class Result( + val isFromAppPredictor: Boolean, + /** + * Input app targets (see [ShortcutLoader.queryShortcuts] the + * shortcuts were process against. + */ + val appTargets: Array<DisplayResolveInfo>, + /** + * Shortcuts grouped by app target. + */ + val shortcutsByApp: Array<ShortcutResultInfo>, + val directShareAppTargetCache: Map<ChooserTarget, AppTarget>, + val directShareShortcutInfoCache: Map<ChooserTarget, ShortcutInfo> + ) + + /** + * Shortcuts grouped by app. + */ + class ShortcutResultInfo( + val appTarget: DisplayResolveInfo, + val shortcuts: List<ChooserTarget?> + ) + + private class ShortcutsAppTargetsPair( + val shortcuts: List<ShareShortcutInfo>, + val appTargets: List<AppTarget>? + ) + + /** + * A wrapper around AppPredictor to facilitate unit-testing. + */ + @VisibleForTesting + open class AppPredictorProxy internal constructor(private val mAppPredictor: AppPredictor) { + /** + * [AppPredictor.registerPredictionUpdates] + */ + open fun registerPredictionUpdates( + callbackExecutor: Executor, callback: AppPredictor.Callback + ) = mAppPredictor.registerPredictionUpdates(callbackExecutor, callback) + + /** + * [AppPredictor.unregisterPredictionUpdates] + */ + open fun unregisterPredictionUpdates(callback: AppPredictor.Callback) = + mAppPredictor.unregisterPredictionUpdates(callback) + + /** + * [AppPredictor.requestPredictionUpdate] + */ + open fun requestPredictionUpdate() = mAppPredictor.requestPredictionUpdate() + } + + companion object { + private const val TAG = "ShortcutLoader" + private val NO_REQUEST = Request(arrayOf()) + + private fun PackageManager.isPackageEnabled(packageName: String): Boolean { + if (TextUtils.isEmpty(packageName)) { + return false + } + return runCatching { + val appInfo = getApplicationInfo( + packageName, + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) + ) + appInfo.enabled && (appInfo.flags and ApplicationInfo.FLAG_SUSPENDED) == 0 + }.getOrDefault(false) + } + } +} diff --git a/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt new file mode 100644 index 00000000..ca94a95d --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ChooserImagePreviewView.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.widget + +import android.animation.ObjectAnimator +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.animation.DecelerateInterpolator +import android.widget.RelativeLayout +import androidx.core.view.isVisible +import com.android.intentresolver.R +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import com.android.internal.R as IntR + +private const val IMAGE_FADE_IN_MILLIS = 150L + +class ChooserImagePreviewView : RelativeLayout, ImagePreviewView { + + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int + ) : this(context, attrs, defStyleAttr, 0) + + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + private val coroutineScope = MainScope() + private lateinit var mainImage: RoundedRectImageView + private lateinit var secondLargeImage: RoundedRectImageView + private lateinit var secondSmallImage: RoundedRectImageView + private lateinit var thirdImage: RoundedRectImageView + + private var loadImageJob: Job? = null + private var transitionStatusElementCallback: TransitionElementStatusCallback? = null + + override fun onFinishInflate() { + LayoutInflater.from(context) + .inflate(R.layout.chooser_image_preview_view_internals, this, true) + mainImage = requireViewById(IntR.id.content_preview_image_1_large) + secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large) + secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small) + thirdImage = requireViewById(IntR.id.content_preview_image_3_small) + } + + /** + * Specifies a transition animation target readiness callback. The callback will be + * invoked once when views preparation is done. + * Should be called before [setImages]. + */ + override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) { + transitionStatusElementCallback = callback + } + + override fun setImages(uris: List<Uri>, imageLoader: ImageLoader) { + loadImageJob?.cancel() + loadImageJob = coroutineScope.launch { + when (uris.size) { + 0 -> hideAllViews() + 1 -> showOneImage(uris, imageLoader) + 2 -> showTwoImages(uris, imageLoader) + else -> showThreeImages(uris, imageLoader) + } + } + } + + private fun hideAllViews() { + mainImage.isVisible = false + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + invokeTransitionViewReadyCallback() + } + + private suspend fun showOneImage(uris: List<Uri>, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage) + } + + private suspend fun showTwoImages(uris: List<Uri>, imageLoader: ImageLoader) { + secondSmallImage.isVisible = false + thirdImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondLargeImage) + } + + private suspend fun showThreeImages(uris: List<Uri>, imageLoader: ImageLoader) { + secondLargeImage.isVisible = false + showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) + thirdImage.setExtraImageCount(uris.size - 3) + } + + private suspend fun showImages( + uris: List<Uri>, imageLoader: ImageLoader, vararg views: RoundedRectImageView + ) = coroutineScope { + for (i in views.indices) { + launch { + loadImageIntoView(views[i], uris[i], imageLoader) + } + } + } + + private suspend fun loadImageIntoView( + view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader + ) { + val bitmap = runCatching { + imageLoader(uri) + }.getOrDefault(null) + if (bitmap == null) { + view.isVisible = false + if (view === mainImage) { + invokeTransitionViewReadyCallback() + } + } else { + view.isVisible = true + view.setImageBitmap(bitmap) + + view.alpha = 0f + ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply { + interpolator = DecelerateInterpolator(1.0f) + duration = IMAGE_FADE_IN_MILLIS + start() + } + if (view === mainImage && transitionStatusElementCallback != null) { + view.waitForPreDraw() + invokeTransitionViewReadyCallback() + } + } + } + + private fun invokeTransitionViewReadyCallback() { + transitionStatusElementCallback?.apply { + if (mainImage.isVisible && mainImage.drawable != null) { + mainImage.transitionName?.let { onTransitionElementReady(it) } + } + onAllTransitionElementsReady() + } + transitionStatusElementCallback = null + } +} diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index a37ef954..a166ef27 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -16,163 +16,34 @@ package com.android.intentresolver.widget -import android.animation.ObjectAnimator -import android.content.Context import android.graphics.Bitmap import android.net.Uri -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewTreeObserver -import android.view.animation.DecelerateInterpolator -import android.widget.RelativeLayout -import androidx.core.view.isVisible -import com.android.intentresolver.R -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import java.util.function.Consumer -import com.android.internal.R as IntR -typealias ImageLoader = suspend (Uri) -> Bitmap? +internal typealias ImageLoader = suspend (Uri) -> Bitmap? -private const val IMAGE_FADE_IN_MILLIS = 150L - -class ImagePreviewView : RelativeLayout { - - constructor(context: Context) : this(context, null) - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - - constructor( - context: Context, attrs: AttributeSet?, defStyleAttr: Int - ) : this(context, attrs, defStyleAttr, 0) - - constructor( - context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int - ) : super(context, attrs, defStyleAttr, defStyleRes) - - private val coroutineScope = MainScope() - private lateinit var mainImage: RoundedRectImageView - private lateinit var secondLargeImage: RoundedRectImageView - private lateinit var secondSmallImage: RoundedRectImageView - private lateinit var thirdImage: RoundedRectImageView - - private var loadImageJob: Job? = null - private var onTransitionViewReadyCallback: Consumer<Boolean>? = null - - override fun onFinishInflate() { - LayoutInflater.from(context).inflate(R.layout.image_preview_view, this, true) - mainImage = requireViewById(IntR.id.content_preview_image_1_large) - secondLargeImage = requireViewById(IntR.id.content_preview_image_2_large) - secondSmallImage = requireViewById(IntR.id.content_preview_image_2_small) - thirdImage = requireViewById(IntR.id.content_preview_image_3_small) - } +interface ImagePreviewView { + fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) + fun setImages(uris: List<Uri>, imageLoader: ImageLoader) /** - * Specifies a transition animation target name and a readiness callback. The callback will be - * invoked once when the view preparation is done i.e. either when an image is loaded into it - * and it is laid out (and it is ready to be draw) or image loading has failed. - * Should be called before [setImages]. - * @param name, transition name - * @param onViewReady, a callback that will be invoked with `true` if the view is ready to - * receive transition animation (the image was loaded successfully) and with `false` otherwise. + * [ImagePreviewView] progressively prepares views for shared element transition and reports + * each successful preparation with [onTransitionElementReady] call followed by + * closing [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is + * zero or more [onTransitionElementReady] calls followed by the final + * [onAllTransitionElementsReady] call. */ - fun setSharedElementTransitionTarget(name: String, onViewReady: Consumer<Boolean>) { - mainImage.transitionName = name - onTransitionViewReadyCallback = onViewReady - } - - fun setImages(uris: List<Uri>, imageLoader: ImageLoader) { - loadImageJob?.cancel() - loadImageJob = coroutineScope.launch { - when (uris.size) { - 0 -> hideAllViews() - 1 -> showOneImage(uris, imageLoader) - 2 -> showTwoImages(uris, imageLoader) - else -> showThreeImages(uris, imageLoader) - } - } - } - - private fun hideAllViews() { - mainImage.isVisible = false - secondLargeImage.isVisible = false - secondSmallImage.isVisible = false - thirdImage.isVisible = false - invokeTransitionViewReadyCallback(runTransitionAnimation = false) - } - - private suspend fun showOneImage(uris: List<Uri>, imageLoader: ImageLoader) { - secondLargeImage.isVisible = false - secondSmallImage.isVisible = false - thirdImage.isVisible = false - showImages(uris, imageLoader, mainImage) - } - - private suspend fun showTwoImages(uris: List<Uri>, imageLoader: ImageLoader) { - secondSmallImage.isVisible = false - thirdImage.isVisible = false - showImages(uris, imageLoader, mainImage, secondLargeImage) - } - - private suspend fun showThreeImages(uris: List<Uri>, imageLoader: ImageLoader) { - secondLargeImage.isVisible = false - showImages(uris, imageLoader, mainImage, secondSmallImage, thirdImage) - thirdImage.setExtraImageCount(uris.size - 3) - } - - private suspend fun showImages( - uris: List<Uri>, imageLoader: ImageLoader, vararg views: RoundedRectImageView - ) = coroutineScope { - for (i in views.indices) { - launch { - loadImageIntoView(views[i], uris[i], imageLoader) - } - } - } - - private suspend fun loadImageIntoView( - view: RoundedRectImageView, uri: Uri, imageLoader: ImageLoader - ) { - val bitmap = runCatching { - imageLoader(uri) - }.getOrDefault(null) - if (bitmap == null) { - view.isVisible = false - if (view === mainImage) { - invokeTransitionViewReadyCallback(runTransitionAnimation = false) - } - } else { - view.isVisible = true - view.setImageBitmap(bitmap) - - view.alpha = 0f - ObjectAnimator.ofFloat(view, "alpha", 0.0f, 1.0f).apply { - interpolator = DecelerateInterpolator(1.0f) - duration = IMAGE_FADE_IN_MILLIS - start() - } - if (view === mainImage && onTransitionViewReadyCallback != null) { - setupPreDrawListener(mainImage) - } - } - } - - private fun setupPreDrawListener(view: View) { - view.viewTreeObserver.addOnPreDrawListener( - object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - view.viewTreeObserver.removeOnPreDrawListener(this) - invokeTransitionViewReadyCallback(runTransitionAnimation = true) - return true - } - } - ) - } - - private fun invokeTransitionViewReadyCallback(runTransitionAnimation: Boolean) { - onTransitionViewReadyCallback?.accept(runTransitionAnimation) - onTransitionViewReadyCallback = null + interface TransitionElementStatusCallback { + /** + * Invoked when a view for a shared transition animation element is ready i.e. the image + * is loaded and the view is laid out. + * @param name shared element name. + */ + fun onTransitionElementReady(name: String) + + /** + * Indicates that all supported transition elements have been reported with + * [onTransitionElementReady]. + */ + fun onAllTransitionElementsReady() } } diff --git a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt new file mode 100644 index 00000000..a7906001 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt @@ -0,0 +1,36 @@ +/* + * 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.widget + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +internal val RecyclerView.areAllChildrenVisible: Boolean + get() { + val count = getChildCount() + if (count == 0) return true + val first = getChildAt(0) + val last = getChildAt(count - 1) + val itemCount = adapter?.itemCount ?: 0 + return getChildAdapterPosition(first) == 0 + && getChildAdapterPosition(last) == itemCount - 1 + && isFullyVisible(first) + && isFullyVisible(last) + } + +private fun RecyclerView.isFullyVisible(view: View): Boolean = + view.left >= paddingLeft && view.right <= width - paddingRight diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt index a941b97a..f2a8b9e8 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt @@ -50,21 +50,6 @@ class ScrollableActionRow : RecyclerView, ActionRow { ) } - private val areAllChildrenVisible: Boolean - get() { - val count = getChildCount() - if (count == 0) return true - val first = getChildAt(0) - val last = getChildAt(count - 1) - return getChildAdapterPosition(first) == 0 - && getChildAdapterPosition(last) == actionsAdapter.itemCount - 1 - && isFullyVisible(first) - && isFullyVisible(last) - } - - private fun isFullyVisible(view: View): Boolean = - view.left >= paddingLeft && view.right <= width - paddingRight - private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() { private val iconSize: Int = context.resources.getDimensionPixelSize(R.dimen.chooser_action_view_icon_size) @@ -103,11 +88,12 @@ class ScrollableActionRow : RecyclerView, ActionRow { ) : RecyclerView.ViewHolder(view) { fun bind(action: ActionRow.Action) { - if (action.icon != null) { - action.icon.setBounds(0, 0, iconSize, iconSize) + action.icon?.let { icon -> + icon.setBounds(0, 0, iconSize, iconSize) // some drawables (edit) does not gets tinted when set to the top of the text // with TextView#setCompoundDrawableRelative - view.setCompoundDrawablesRelative(null, action.icon, null, null) + tintIcon(icon, view) + view.setCompoundDrawablesRelative(null, icon, null, null) } view.text = action.label ?: "" view.setOnClickListener { diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt new file mode 100644 index 00000000..467c404a --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -0,0 +1,178 @@ +/* + * 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.widget + +import android.content.Context +import android.graphics.Rect +import android.net.Uri +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.intentresolver.R +import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus + +private const val TRANSITION_NAME = "screenshot_preview_image" + +class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor( + context: Context, attrs: AttributeSet?, defStyleAttr: Int + ) : super(context, attrs, defStyleAttr) { + layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + adapter = Adapter(context) + val spacing = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 5f, context.resources.displayMetrics + ).toInt() + addItemDecoration(SpacingDecoration(spacing)) + } + + private val previewAdapter get() = adapter as Adapter + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + setOverScrollMode( + if (areAllChildrenVisible) View.OVER_SCROLL_NEVER else View.OVER_SCROLL_ALWAYS + ) + } + + override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) { + previewAdapter.transitionStatusElementCallback = callback + } + + override fun setImages(uris: List<Uri>, imageLoader: ImageLoader) { + previewAdapter.setImages(uris, imageLoader) + } + + private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() { + private val uris = ArrayList<Uri>() + private var imageLoader: ImageLoader? = null + var transitionStatusElementCallback: TransitionElementStatusCallback? = null + + fun setImages(uris: List<Uri>, imageLoader: ImageLoader) { + this.uris.clear() + this.uris.addAll(uris) + this.imageLoader = imageLoader + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder { + return ViewHolder( + LayoutInflater.from(context) + .inflate(R.layout.image_preview_image_item, parent, false) + ) + } + + override fun getItemCount(): Int = uris.size + + override fun onBindViewHolder(vh: ViewHolder, position: Int) { + vh.bind( + uris[position], + imageLoader ?: error("ImageLoader is missing"), + if (position == 0 && transitionStatusElementCallback != null) { + this::onTransitionElementReady + } else { + null + } + ) + } + + override fun onViewRecycled(vh: ViewHolder) { + vh.unbind() + } + + override fun onFailedToRecycleView(vh: ViewHolder): Boolean { + vh.unbind() + return super.onFailedToRecycleView(vh) + } + + private fun onTransitionElementReady(name: String) { + transitionStatusElementCallback?.apply { + onTransitionElementReady(name) + onAllTransitionElementsReady() + } + transitionStatusElementCallback = null + } + } + + private class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val image = view.requireViewById<ImageView>(R.id.image) + private var scope: CoroutineScope? = null + + fun bind( + uri: Uri, + imageLoader: ImageLoader, + previewReadyCallback: ((String) -> Unit)? + ) { + image.setImageDrawable(null) + image.transitionName = if (previewReadyCallback != null) { + TRANSITION_NAME + } else { + null + } + resetScope().launch { + loadImage(uri, imageLoader, previewReadyCallback) + } + } + + private suspend fun loadImage( + uri: Uri, + imageLoader: ImageLoader, + previewReadyCallback: ((String) -> Unit)? + ) { + val bitmap = runCatching { + // it's expected for all loading/caching optimizations to be implemented by the + // loader + imageLoader(uri) + }.getOrNull() + image.setImageBitmap(bitmap) + previewReadyCallback?.let { callback -> + image.waitForPreDraw() + callback(TRANSITION_NAME) + } + } + + private fun resetScope(): CoroutineScope = + (MainScope() + Dispatchers.Main.immediate).also { + scope?.cancel() + scope = it + } + + fun unbind() { + scope?.cancel() + scope = null + } + } + + private class SpacingDecoration(private val margin: Int) : RecyclerView.ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) { + outRect.set(margin, 0, margin, 0) + } + } +} diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt new file mode 100644 index 00000000..11b7c146 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ViewExtensions.kt @@ -0,0 +1,39 @@ +/* + * 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.widget + +import android.util.Log +import android.view.View +import androidx.core.view.OneShotPreDrawListener +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicBoolean + +internal suspend fun View.waitForPreDraw(): Unit = suspendCancellableCoroutine { continuation -> + val isResumed = AtomicBoolean(false) + val callback = OneShotPreDrawListener.add( + this, + Runnable { + if (isResumed.compareAndSet(false, true)) { + continuation.resumeWith(Result.success(Unit)) + } else { + // it's not really expected but in some unknown corner-case let's not crash + Log.e("waitForPreDraw", "An attempt to resume a completed coroutine", Exception()) + } + } + ) + continuation.invokeOnCancellation { callback.removeListener() } +} diff --git a/java/tests/Android.bp b/java/tests/Android.bp index 2913d128..4e835ec8 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -21,11 +21,16 @@ android_test { "IntentResolver-core", "androidx.test.rules", "androidx.test.ext.junit", + "androidx.test.espresso.contrib", "mockito-target-minus-junit4", "androidx.test.espresso.core", + "androidx.lifecycle_lifecycle-common-java8", + "androidx.lifecycle_lifecycle-extensions", + "androidx.lifecycle_lifecycle-runtime-ktx", "truth-prebuilt", "testables", "testng", + "kotlinx_coroutines_test", ], test_suites: ["general-tests"], sdk_version: "core_platform", diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt new file mode 100644 index 00000000..af134fcd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -0,0 +1,154 @@ +/* + * 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 android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Resources +import android.graphics.drawable.Icon +import android.service.chooser.ChooserAction +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.flags.FeatureFlagRepository +import com.android.intentresolver.flags.Flags +import com.google.common.collect.ImmutableList +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.function.Consumer + +@RunWith(AndroidJUnit4::class) +class ChooserActionFactoryTest { + private val context = InstrumentationRegistry.getInstrumentation().getContext() + + private val logger = mock<ChooserActivityLogger>() + private val flags = mock<FeatureFlagRepository>() + private val actionLabel = "Action label" + private val testAction = "com.android.intentresolver.testaction" + private val countdown = CountDownLatch(1) + private val testReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Just doing at most a single countdown per test. + countdown.countDown() + } + } + private object resultConsumer : Consumer<Int> { + var latestReturn = Integer.MIN_VALUE + + override fun accept(resultCode: Int) { + latestReturn = resultCode + } + + } + + @Before + fun setup() { + whenever(flags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)).thenReturn(true) + context.registerReceiver(testReceiver, IntentFilter(testAction)) + } + + @After + fun teardown() { + context.unregisterReceiver(testReceiver) + } + + @Test + fun testCreateCustomActions() { + val factory = createFactory() + + val customActions = factory.createCustomActions() + + assertThat(customActions.size).isEqualTo(1) + assertThat(customActions[0].label).isEqualTo(actionLabel) + + // click it + customActions[0].onClicked.run() + + Mockito.verify(logger).logCustomActionSelected(eq(0)) + assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) + // Verify the pendingintent has been called + countdown.await(500, TimeUnit.MILLISECONDS) + } + + @Test + fun testNoModifyShareAction() { + val factory = createFactory(includeModifyShare = false) + + assertThat(factory.modifyShareAction).isNull() + } + + @Test + fun testNoModifyShareAction_flagDisabled() { + whenever(flags.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)).thenReturn(false) + val factory = createFactory(includeModifyShare = true) + + assertThat(factory.modifyShareAction).isNull() + } + + @Test + fun testModifyShareAction() { + val factory = createFactory(includeModifyShare = true) + + factory.modifyShareAction!!.run() + + Mockito.verify(logger).logActionSelected( + eq(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE)) + assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) + // Verify the pendingintent has been called + countdown.await(500, TimeUnit.MILLISECONDS) + } + + private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { + val testPendingIntent = PendingIntent.getActivity(context, 0, Intent(testAction),0) + val targetIntent = Intent() + val action = ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + actionLabel, + testPendingIntent + ).build() + val chooserRequest = mock<ChooserRequestParameters>() + whenever(chooserRequest.targetIntent).thenReturn(targetIntent) + whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) + + if (includeModifyShare) { + whenever(chooserRequest.modifyShareAction).thenReturn(testPendingIntent) + } + + return ChooserActionFactory( + context, + chooserRequest, + flags, + mock<ChooserIntegratedDeviceComponents>(), + logger, + Consumer<Boolean>{}, + Callable<View?>{null}, + mock<ChooserActionFactory.ActionActivityStarter>(), + resultConsumer) + } +}
\ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java index 705a3228..aa42c24c 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java @@ -36,6 +36,7 @@ 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.contentpreview.ContentPreviewType; import com.android.internal.logging.InstanceId; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEventLogger; @@ -112,24 +113,26 @@ public final class ChooserActivityLoggerTest { @Test public void testLogShareStarted() { - final int eventId = -1; // Passed-in eventId is unused. TODO: remove from method signature. final String packageName = "com.test.foo"; final String mimeType = "text/plain"; final int appProvidedDirectTargets = 123; final int appProvidedAppTargets = 456; final boolean workProfile = true; - final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_FILE; + final int previewType = ContentPreviewType.CONTENT_PREVIEW_FILE; final String intentAction = Intent.ACTION_SENDTO; + final int numCustomActions = 3; + final boolean modifyShareProvided = true; mChooserLogger.logShareStarted( - eventId, packageName, mimeType, appProvidedDirectTargets, appProvidedAppTargets, workProfile, previewType, - intentAction); + intentAction, + numCustomActions, + modifyShareProvided); verify(mFrameworkLog).write( eq(FrameworkStatsLog.SHARESHEET_STARTED), @@ -141,7 +144,9 @@ public final class ChooserActivityLoggerTest { eq(appProvidedAppTargets), eq(workProfile), eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE), - eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO)); + eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO), + /* custom actions provided */ eq(numCustomActions), + /* reselection action provided */ eq(modifyShareProvided)); } @Test @@ -203,6 +208,17 @@ public final class ChooserActivityLoggerTest { } @Test + public void testLogCustomActionSelected() { + final int position = 4; + mChooserLogger.logCustomActionSelected(position); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.RANKING_SELECTED), + eq(SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId()), + any(), anyInt(), eq(position), eq(false)); + } + + @Test public void testLogDirectShareTargetReceived() { final int category = MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER; final int latency = 123; @@ -218,7 +234,7 @@ public final class ChooserActivityLoggerTest { @Test public void testLogActionShareWithPreview() { - final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT; + final int previewType = ContentPreviewType.CONTENT_PREVIEW_TEXT; mChooserLogger.logActionShareWithPreview(previewType); diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java index 5df0d4a2..f0c459e5 100644 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -29,8 +29,8 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.shortcuts.ShortcutLoader; import java.util.function.Consumer; @@ -58,8 +58,8 @@ public class ChooserActivityOverrideData { public Function<TargetInfo, Boolean> onSafelyStartCallback; public Function2<UserHandle, Consumer<ShortcutLoader.Result>, ShortcutLoader> shortcutLoaderFactory = (userHandle, callback) -> null; - public ResolverListController resolverListController; - public ResolverListController workResolverListController; + public ChooserActivity.ChooserListController resolverListController; + public ChooserActivity.ChooserListController workResolverListController; public Boolean isVoiceInteraction; public boolean isImageType; public Cursor resolverCursor; @@ -72,10 +72,11 @@ public class ChooserActivityOverrideData { public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; public Integer myUserId; - public QuietModeManager mQuietModeManager; + public WorkProfileAvailabilityManager mWorkProfileAvailability; public MyUserIdProvider mMyUserIdProvider; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public PackageManager packageManager; + public FeatureFlagRepository featureFlagRepository; public void reset() { onSafelyStartCallback = null; @@ -85,8 +86,8 @@ public class ChooserActivityOverrideData { isImageType = false; resolverCursor = null; resolverForceException = false; - resolverListController = mock(ResolverListController.class); - workResolverListController = mock(ResolverListController.class); + resolverListController = mock(ChooserActivity.ChooserListController.class); + workResolverListController = mock(ChooserActivity.ChooserListController.class); chooserActivityLogger = mock(ChooserActivityLogger.class); alternateProfileSetting = 0; resources = null; @@ -95,23 +96,26 @@ public class ChooserActivityOverrideData { isQuietModeEnabled = false; myUserId = null; packageManager = null; - mQuietModeManager = new QuietModeManager() { + mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { @Override - public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + public boolean isQuietModeEnabled() { return isQuietModeEnabled; } @Override - public void requestQuietModeEnabled(boolean enabled, - UserHandle workProfileUserHandle) { - isQuietModeEnabled = enabled; + public boolean isWorkProfileUserUnlocked() { + return true; } @Override - public void markWorkProfileEnabledBroadcastReceived() { + public void requestQuietModeEnabled(boolean enabled) { + isQuietModeEnabled = enabled; } @Override + public void markWorkProfileEnabledBroadcastReceived() {} + + @Override public boolean isWaitingToEnableWorkProfile() { return false; } @@ -128,6 +132,7 @@ public class ChooserActivityOverrideData { mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); + featureFlagRepository = null; } private ChooserActivityOverrideData() {} diff --git a/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt b/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt new file mode 100644 index 00000000..9a5dabdb --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt @@ -0,0 +1,71 @@ +/* + * 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 android.content.ComponentName +import android.provider.Settings +import android.testing.TestableContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChooserIntegratedDeviceComponentsTest { + private val secureSettings = mock<SecureSettings>() + private val testableContext = + TestableContext(InstrumentationRegistry.getInstrumentation().getContext()) + + @Test + fun testEditorAndNearby() { + val resources = testableContext.getOrCreateTestableResources() + + resources.addOverride(R.string.config_systemImageEditor, "") + resources.addOverride(R.string.config_defaultNearbySharingComponent, "") + + var components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) + + assertThat(components.editSharingComponent).isNull() + assertThat(components.nearbySharingComponent).isNull() + + val editor = ComponentName.unflattenFromString("com.android/com.android.Editor") + val nearby = ComponentName.unflattenFromString("com.android/com.android.nearby") + + resources.addOverride(R.string.config_systemImageEditor, editor?.flattenToString()) + resources.addOverride( + R.string.config_defaultNearbySharingComponent, nearby?.flattenToString()) + + components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) + + assertThat(components.editSharingComponent).isEqualTo(editor) + assertThat(components.nearbySharingComponent).isEqualTo(nearby) + + val anotherNearby = + ComponentName.unflattenFromString("com.android/com.android.another_nearby") + whenever( + secureSettings.getString( + any(), + eq(Settings.Secure.NEARBY_SHARING_COMPONENT) + ) + ).thenReturn(anotherNearby?.flattenToString()) + + components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) + + assertThat(components.nearbySharingComponent).isEqualTo(anotherNearby) + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt new file mode 100644 index 00000000..50c37c7f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt @@ -0,0 +1,61 @@ +/* + * 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 android.content.Context +import android.content.Intent +import android.content.IntentSender +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.intentresolver.chooser.TargetInfo +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import java.util.function.Consumer +import org.junit.Assert.assertEquals + +@RunWith(AndroidJUnit4::class) +class ChooserRefinementManagerTest { + @Test + fun testMaybeHandleSelection() { + val intentSender = mock<IntentSender>() + val refinementManager = ChooserRefinementManager( + mock<Context>(), + intentSender, + Consumer<TargetInfo>{}, + Runnable{}) + + val intents = listOf(Intent(Intent.ACTION_VIEW), Intent(Intent.ACTION_EDIT)) + val targetInfo = mock<TargetInfo>{ + whenever(allSourceIntents).thenReturn(intents) + } + + refinementManager.maybeHandleSelection(targetInfo) + + val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) + Mockito.verify(intentSender).sendIntent( + any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) + + val intent = intentCaptor.value + assertEquals(intents[0], intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)) + + val alternates = + intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java) + assertEquals(1, alternates?.size) + assertEquals(intents[1], alternates?.get(0)) + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 97de97f5..d4ae666b 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import static org.mockito.Mockito.when; - import android.annotation.Nullable; import android.app.prediction.AppPredictor; import android.app.usage.UsageStatsManager; @@ -30,17 +28,14 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.database.Cursor; -import android.graphics.Bitmap; import android.net.Uri; import android.os.UserHandle; -import android.util.Size; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.flags.FeatureFlagRepository; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -121,15 +116,13 @@ public class ChooserWrapperActivity } @Override - protected ComponentName getNearbySharingComponent() { - // an arbitrary pre-installed activity that handles this type of intent - return ComponentName.unflattenFromString("com.google.android.apps.messaging/" - + "com.google.android.apps.messaging.ui.conversationlist.ShareIntentActivity"); - } - - @Override - protected TargetInfo getNearbySharingTarget(Intent originalIntent) { - return NotSelectableTargetInfo.newEmptyTargetInfo(); + protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { + return new ChooserIntegratedDeviceComponents( + /* editSharingComponent=*/ null, + // An arbitrary pre-installed activity that handles this type of intent: + /* nearbySharingComponent=*/ new ComponentName( + "com.google.android.apps.messaging", + ".ui.conversationlist.ShareIntentActivity")); } @Override @@ -165,15 +158,15 @@ public class ChooserWrapperActivity } @Override - protected QuietModeManager createQuietModeManager() { - if (sOverrides.mQuietModeManager != null) { - return sOverrides.mQuietModeManager; + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + if (sOverrides.mWorkProfileAvailability != null) { + return sOverrides.mWorkProfileAvailability; } - return super.createQuietModeManager(); + return super.createWorkProfileAvailabilityManager(); } @Override - public void safelyStartActivity(com.android.intentresolver.chooser.TargetInfo cti) { + public void safelyStartActivity(TargetInfo cti) { if (sOverrides.onSafelyStartCallback != null && sOverrides.onSafelyStartCallback.apply(cti)) { return; @@ -182,12 +175,10 @@ public class ChooserWrapperActivity } @Override - protected ResolverListController createListController(UserHandle userHandle) { + protected ChooserListController createListController(UserHandle userHandle) { if (userHandle == UserHandle.SYSTEM) { - when(sOverrides.resolverListController.getUserHandle()).thenReturn(UserHandle.SYSTEM); return sOverrides.resolverListController; } - when(sOverrides.workResolverListController.getUserHandle()).thenReturn(userHandle); return sOverrides.workResolverListController; } @@ -208,11 +199,10 @@ public class ChooserWrapperActivity } @Override - protected Bitmap loadThumbnail(Uri uri, Size size) { - if (sOverrides.previewThumbnail != null) { - return sOverrides.previewThumbnail; - } - return super.loadThumbnail(uri, size); + protected ImageLoader createPreviewImageLoader() { + return new TestPreviewImageLoader( + super.createPreviewImageLoader(), + () -> sOverrides.previewThumbnail); } @Override @@ -290,4 +280,12 @@ public class ChooserWrapperActivity return super.createShortcutLoader( context, appPredictor, userHandle, targetIntentFilter, callback); } + + @Override + protected FeatureFlagRepository createFeatureFlagRepository() { + if (sOverrides.featureFlagRepository != null) { + return sOverrides.featureFlagRepository; + } + return super.createFeatureFlagRepository(); + } } diff --git a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt new file mode 100644 index 00000000..9ea9dfa7 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt @@ -0,0 +1,112 @@ +/* + * 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 android.content.res.Resources +import android.view.View +import android.view.Window +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +private const val TIMEOUT_MS = 200 + +@OptIn(ExperimentalCoroutinesApi::class) +class EnterTransitionAnimationDelegateTest { + private val elementName = "shared-element" + private val scheduler = TestCoroutineScheduler() + 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 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 + } + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + lifecycleOwner.state = Lifecycle.State.CREATED + } + + @After + fun cleanup() { + lifecycleOwner.state = Lifecycle.State.DESTROYED + Dispatchers.resetMain() + } + + @Test + fun test_postponeTransition_timeout() { + testSubject.postponeTransition() + testSubject.markOffsetCalculated() + + scheduler.advanceTimeBy(TIMEOUT_MS + 1L) + verify(activity, times(1)).startPostponedEnterTransition() + verify(windowMock, never()).setWindowAnimations(anyInt()) + } + + @Test + fun test_postponeTransition_animation_resumes_only_once() { + testSubject.postponeTransition() + testSubject.markOffsetCalculated() + testSubject.onTransitionElementReady(elementName) + testSubject.markOffsetCalculated() + testSubject.onTransitionElementReady(elementName) + + scheduler.advanceTimeBy(TIMEOUT_MS + 1L) + verify(activity, times(1)).startPostponedEnterTransition() + } + + @Test + fun test_postponeTransition_resume_animation_conditions() { + testSubject.postponeTransition() + verify(activity, never()).startPostponedEnterTransition() + + testSubject.markOffsetCalculated() + verify(activity, never()).startPostponedEnterTransition() + + testSubject.onAllTransitionElementsReady() + verify(activity, times(1)).startPostponedEnterTransition() + } +} diff --git a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt new file mode 100644 index 00000000..3fa01bcc --- /dev/null +++ b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt @@ -0,0 +1,56 @@ +/* + * 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 com.android.systemui.flags.BooleanFlag +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Ignores tests annotated with [RequireFeatureFlags] which flag requirements does not + * meet in the active flag set. + * @param flags active flag set + */ +internal class FeatureFlagRule(flags: Map<BooleanFlag, Boolean>) : TestRule { + private val flags = flags.entries.fold(HashMap<String, Boolean>()) { map, (key, value) -> + map.apply { + put(key.name, value) + } + } + private val skippingStatement = object : Statement() { + override fun evaluate() = Unit + } + + override fun apply(base: Statement, description: Description): Statement { + val annotation = description.annotations.firstOrNull { + it is RequireFeatureFlags + } as? RequireFeatureFlags + ?: return base + + if (annotation.flags.size != annotation.values.size) { + error("${description.className}#${description.methodName}: inconsistent number of" + + " flags and values in $annotation") + } + for (i in annotation.flags.indices) { + val flag = annotation.flags[i] + val value = annotation.values[i] + if (flags.getOrDefault(flag, !value) != value) return skippingStatement + } + return base + } +} diff --git a/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt new file mode 100644 index 00000000..f327e19e --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ImagePreviewImageLoaderTest.kt @@ -0,0 +1,101 @@ +/* + * 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 android.content.ContentResolver +import android.content.Context +import android.content.res.Resources +import android.net.Uri +import android.util.Size +import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class ImagePreviewImageLoaderTest { + private val imageSize = Size(300, 300) + private val uriOne = Uri.parse("content://org.package.app/image-1.png") + private val uriTwo = Uri.parse("content://org.package.app/image-2.png") + private val contentResolver = mock<ContentResolver>() + private val resources = mock<Resources> { + whenever(getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen)) + .thenReturn(imageSize.width) + } + private val context = mock<Context> { + whenever(this.resources).thenReturn(this@ImagePreviewImageLoaderTest.resources) + whenever(this.contentResolver).thenReturn(this@ImagePreviewImageLoaderTest.contentResolver) + } + private val scheduler = TestCoroutineScheduler() + private val lifecycleOwner = TestLifecycleOwner() + private val dispatcher = UnconfinedTestDispatcher(scheduler) + private val testSubject = ImagePreviewImageLoader( + context, lifecycleOwner.lifecycle, 1, dispatcher + ) + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + lifecycleOwner.state = Lifecycle.State.CREATED + } + + @After + fun cleanup() { + lifecycleOwner.state = Lifecycle.State.DESTROYED + Dispatchers.resetMain() + } + + @Test + fun test_prePopulate() = runTest { + testSubject.prePopulate(listOf(uriOne, uriTwo)) + + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) + + testSubject(uriOne) + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + } + + @Test + fun test_invoke_return_cached_image() = runTest { + testSubject(uriOne) + testSubject(uriOne) + + verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) + } + + @Test + fun test_invoke_old_records_evicted_from_the_cache() = runTest { + testSubject(uriOne) + testSubject(uriTwo) + testSubject(uriTwo) + testSubject(uriOne) + + verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) + } +} diff --git a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt index 159c6d6a..aaa7a282 100644 --- a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt +++ b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt @@ -26,6 +26,7 @@ package com.android.intentresolver import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatcher +import org.mockito.ArgumentMatchers import org.mockito.Mockito import org.mockito.stubbing.OngoingStubbing @@ -144,3 +145,5 @@ inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> */ inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> = kotlinArgumentCaptor<T>().apply{ block() }.allValues + +inline fun <reified T> anyOrNull() = ArgumentMatchers.argThat(ArgumentMatcher<T?> { true }) diff --git a/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt b/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt new file mode 100644 index 00000000..1ddf7462 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt @@ -0,0 +1,23 @@ +/* + * 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 + +/** + * Specifies expected feature flag values for a test. + */ +@Target(AnnotationTarget.FUNCTION) +annotation class RequireFeatureFlags(val flags: Array<String>, val values: BooleanArray) diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java index 62c16ff5..ae1b99f8 100644 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java @@ -54,7 +54,6 @@ import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.R; @@ -101,10 +100,7 @@ public class ResolverActivityTest { Intent sendIntent = createSendImageIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); @@ -133,10 +129,7 @@ public class ResolverActivityTest { Intent sendIntent = createSendImageIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); waitForIdle(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); @@ -178,10 +171,7 @@ public class ResolverActivityTest { Intent sendIntent = createSendImageIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); waitForIdle(); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); @@ -211,10 +201,7 @@ public class ResolverActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); when(sOverrides.resolverListController.getLastChosen()) .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); @@ -277,10 +264,7 @@ public class ResolverActivityTest { createResolvedComponentsForTestWithOtherProfile(3); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource()); @@ -319,10 +303,7 @@ public class ResolverActivityTest { createResolvedComponentsForTestWithOtherProfile(3); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); when(sOverrides.resolverListController.getLastChosen()) .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); @@ -761,10 +742,7 @@ public class ResolverActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); when(sOverrides.resolverListController.getLastChosen()) .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); @@ -837,22 +815,33 @@ public class ResolverActivityTest { } private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos) { + setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); + } + + private void setupResolverControllers( List<ResolvedComponentInfo> personalResolvedComponentInfos, List<ResolvedComponentInfo> workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + when(sOverrides.resolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(), Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(workResolvedComponentInfos); - when(sOverrides.workResolverListController.getResolversForIntentAsUser(Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.of(10)))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); } } diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java index fb928e09..b6b32b5a 100644 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -36,20 +36,33 @@ public class ResolverDataProvider { static private int USER_SOMEONE_ELSE = 10; - static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfo(int i) { - return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), - createResolverIntent(i), createResolveInfo(i, UserHandle.USER_CURRENT)); + static ResolvedComponentInfo createResolvedComponentInfo(int i) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, UserHandle.USER_CURRENT)); } - static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { - return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), - createResolverIntent(i), createResolveInfo(i, USER_SOMEONE_ELSE)); + static ResolvedComponentInfo createResolvedComponentInfo( + ComponentName componentName, Intent intent) { + return new ResolvedComponentInfo( + componentName, + intent, + createResolveInfo(componentName, UserHandle.USER_CURRENT)); } - static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, - int userId) { - return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), - createResolverIntent(i), createResolveInfo(i, userId)); + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, USER_SOMEONE_ELSE)); + } + + static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, int userId) { + return new ResolvedComponentInfo( + createComponentName(i), + createResolverIntent(i), + createResolveInfo(i, userId)); } public static ComponentName createComponentName(int i) { @@ -64,6 +77,13 @@ public class ResolverDataProvider { return resolveInfo; } + public static ResolveInfo createResolveInfo(ComponentName componentName, int userId) { + final ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = createActivityInfo(componentName); + resolveInfo.targetUserId = userId; + return resolveInfo; + } + static ActivityInfo createActivityInfo(int i) { ActivityInfo ai = new ActivityInfo(); ai.name = "activity_name" + i; @@ -75,6 +95,18 @@ public class ResolverDataProvider { return ai; } + static ActivityInfo createActivityInfo(ComponentName componentName) { + ActivityInfo ai = new ActivityInfo(); + ai.name = componentName.getClassName(); + ai.packageName = componentName.getPackageName(); + ai.enabled = true; + ai.exported = true; + ai.permission = null; + ai.applicationInfo = createApplicationInfo(); + ai.applicationInfo.packageName = componentName.getPackageName(); + return ai; + } + static ApplicationInfo createApplicationInfo() { ApplicationInfo ai = new ApplicationInfo(); ai.name = "app_name"; diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java index 239bffe0..d67b73af 100644 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -31,7 +31,6 @@ import android.os.UserHandle; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager; import com.android.intentresolver.chooser.TargetInfo; import java.util.List; @@ -88,11 +87,11 @@ public class ResolverWrapperActivity extends ResolverActivity { } @Override - protected QuietModeManager createQuietModeManager() { - if (sOverrides.mQuietModeManager != null) { - return sOverrides.mQuietModeManager; + protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { + if (sOverrides.mWorkProfileAvailability != null) { + return sOverrides.mWorkProfileAvailability; } - return super.createQuietModeManager(); + return super.createWorkProfileAvailabilityManager(); } ResolverWrapperAdapter getAdapter() { @@ -130,10 +129,8 @@ public class ResolverWrapperActivity extends ResolverActivity { @Override protected ResolverListController createListController(UserHandle userHandle) { if (userHandle == UserHandle.SYSTEM) { - when(sOverrides.resolverListController.getUserHandle()).thenReturn(UserHandle.SYSTEM); return sOverrides.resolverListController; } - when(sOverrides.workResolverListController.getUserHandle()).thenReturn(userHandle); return sOverrides.workResolverListController; } @@ -175,7 +172,7 @@ public class ResolverWrapperActivity extends ResolverActivity { public Integer myUserId; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; - public QuietModeManager mQuietModeManager; + public WorkProfileAvailabilityManager mWorkProfileAvailability; public MyUserIdProvider mMyUserIdProvider; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; @@ -190,23 +187,26 @@ public class ResolverWrapperActivity extends ResolverActivity { hasCrossProfileIntents = true; isQuietModeEnabled = false; - mQuietModeManager = new QuietModeManager() { + mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { @Override - public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + public boolean isQuietModeEnabled() { return isQuietModeEnabled; } @Override - public void requestQuietModeEnabled(boolean enabled, - UserHandle workProfileUserHandle) { - isQuietModeEnabled = enabled; + public boolean isWorkProfileUserUnlocked() { + return true; } @Override - public void markWorkProfileEnabledBroadcastReceived() { + public void requestQuietModeEnabled(boolean enabled) { + isQuietModeEnabled = enabled; } @Override + public void markWorkProfileEnabledBroadcastReceived() {} + + @Override public boolean isWaitingToEnableWorkProfile() { return false; } diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt new file mode 100644 index 00000000..b9047712 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt @@ -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. + */ + +package com.android.intentresolver + +import com.android.intentresolver.flags.FeatureFlagRepository +import com.android.systemui.flags.BooleanFlag +import com.android.systemui.flags.ReleasedFlag +import com.android.systemui.flags.UnreleasedFlag + +class TestFeatureFlagRepository( + private val overrides: Map<BooleanFlag, Boolean> +) : FeatureFlagRepository { + override fun isEnabled(flag: UnreleasedFlag): Boolean = getValue(flag) + override fun isEnabled(flag: ReleasedFlag): Boolean = getValue(flag) + + private fun getValue(flag: BooleanFlag) = overrides.getOrDefault(flag, flag.default) +} diff --git a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt new file mode 100644 index 00000000..f47e343f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt @@ -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. + */ + +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 fun getLifecycle(): Lifecycle = 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/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt new file mode 100644 index 00000000..cfe041dd --- /dev/null +++ b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.graphics.Bitmap +import android.net.Uri +import java.util.function.Consumer + +internal class TestPreviewImageLoader( + private val imageLoader: ImageLoader, + private val imageOverride: () -> Bitmap? +) : ImageLoader { + override fun loadImage(uri: Uri, callback: Consumer<Bitmap?>) { + val override = imageOverride() + if (override != null) { + callback.accept(override) + } else { + imageLoader.loadImage(uri, callback) + } + } + + override suspend fun invoke(uri: Uri): Bitmap? = imageOverride() ?: imageLoader(uri) + override fun prePopulate(uris: List<Uri>) = Unit +} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index af2557ef..9ffd02d4 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -20,6 +20,7 @@ import static android.app.Activity.RESULT_OK; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.longClick; import static androidx.test.espresso.action.ViewActions.swipeUp; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; @@ -55,13 +56,16 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.app.PendingIntent; import android.app.usage.UsageStatsManager; +import android.content.BroadcastReceiver; import android.content.ClipData; import android.content.ClipDescription; import android.content.ClipboardManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -69,6 +73,7 @@ import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.content.pm.ShortcutManager.ShareShortcutInfo; import android.content.res.Configuration; +import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Canvas; @@ -79,25 +84,29 @@ import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; import android.provider.DeviceConfig; +import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; import android.util.HashedStringCache; import android.util.Pair; import android.util.SparseArray; import android.view.View; +import android.view.ViewGroup; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import androidx.test.espresso.contrib.RecyclerViewActions; import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.flags.Flags; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.systemui.flags.BooleanFlag; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -106,6 +115,8 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.mockito.ArgumentCaptor; @@ -117,6 +128,8 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; @@ -150,14 +163,40 @@ public class UnbundledChooserActivityTest { return mock; }; + private static final List<BooleanFlag> ALL_FLAGS = + Arrays.asList( + Flags.SHARESHEET_CUSTOM_ACTIONS, + Flags.SHARESHEET_RESELECTION_ACTION, + Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW, + Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW); + + private static final Map<BooleanFlag, Boolean> ALL_FLAGS_OFF = + createAllFlagsOverride(false); + private static final Map<BooleanFlag, Boolean> ALL_FLAGS_ON = + createAllFlagsOverride(true); + @Parameterized.Parameters public static Collection packageManagers() { return Arrays.asList(new Object[][] { - {0, "Default PackageManager", DEFAULT_PM}, - {1, "No App Prediction Service", NO_APP_PREDICTION_SERVICE_PM} + // Default PackageManager and all flags off + { DEFAULT_PM, ALL_FLAGS_OFF }, + // Default PackageManager and all flags on + { DEFAULT_PM, ALL_FLAGS_ON }, + // No App Prediction Service and all flags off + { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF }, + // No App Prediction Service and all flags on + { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_ON } }); } + private static Map<BooleanFlag, Boolean> createAllFlagsOverride(boolean value) { + HashMap<BooleanFlag, Boolean> overrides = new HashMap<>(ALL_FLAGS.size()); + for (BooleanFlag flag : ALL_FLAGS) { + overrides.put(flag, value); + } + return overrides; + } + /* -------- * Subclasses can override the following methods to customize test behavior. * -------- @@ -177,6 +216,8 @@ public class UnbundledChooserActivityTest { .adoptShellPermissionIdentity(); cleanOverrideData(); + ChooserActivityOverrideData.getInstance().featureFlagRepository = + new TestFeatureFlagRepository(mFlags); } /** @@ -209,11 +250,13 @@ public class UnbundledChooserActivityTest { * -------- */ + @Rule + public final TestRule mRule; + // Shared test code references the activity under test as ChooserActivity, the common ancestor // of any (inheritance-based) chooser implementation. For testing purposes, that activity will // usually be cast to IChooserWrapper to expose instrumentation. - @Rule - public ActivityTestRule<ChooserActivity> mActivityRule = + private ActivityTestRule<ChooserActivity> mActivityRule = new ActivityTestRule<>(ChooserActivity.class, false, false) { @Override public ChooserActivity launchActivity(Intent clientIntent) { @@ -240,16 +283,20 @@ public class UnbundledChooserActivityTest { private static final int CONTENT_PREVIEW_IMAGE = 1; private static final int CONTENT_PREVIEW_FILE = 2; private static final int CONTENT_PREVIEW_TEXT = 3; - private Function<PackageManager, PackageManager> mPackageManagerOverride; - private int mTestNum; + + private final Function<PackageManager, PackageManager> mPackageManagerOverride; + private final Map<BooleanFlag, Boolean> mFlags; public UnbundledChooserActivityTest( - int testNum, - String testName, - Function<PackageManager, PackageManager> packageManagerOverride) { + Function<PackageManager, PackageManager> packageManagerOverride, + Map<BooleanFlag, Boolean> flags) { mPackageManagerOverride = packageManagerOverride; - mTestNum = testNum; + mFlags = flags; + + mRule = RuleChain + .outerRule(new FeatureFlagRule(flags)) + .around(mActivityRule); } private void setDeviceConfigProperty( @@ -284,16 +331,7 @@ public class UnbundledChooserActivityTest { Intent viewIntent = createViewTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( Intent.createChooser(viewIntent, "chooser test")); @@ -308,16 +346,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); waitForIdle(); onView(withId(android.R.id.title)) @@ -329,16 +358,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(android.R.id.title)) @@ -350,16 +370,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntentWithPreview(null, null); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_title)) @@ -374,16 +385,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_title)) @@ -401,16 +403,7 @@ public class UnbundledChooserActivityTest { Uri.parse("tel:(+49)12345789")); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_title)) @@ -428,16 +421,7 @@ public class UnbundledChooserActivityTest { ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_title)) @@ -451,16 +435,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -506,16 +481,7 @@ public class UnbundledChooserActivityTest { } resolvedComponentInfos.addAll(infosToStack); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -547,16 +513,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -582,16 +539,7 @@ public class UnbundledChooserActivityTest { @Ignore // b/148158199 @Test public void noResultsFromPackageManager() { - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(null); + setupResolverControllers(null); Intent sendIntent = createSendTextIntent(); final ChooserActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -618,16 +566,7 @@ public class UnbundledChooserActivityTest { }; List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); Intent sendIntent = createSendTextIntent(); final ChooserActivity activity = @@ -679,11 +618,7 @@ public class UnbundledChooserActivityTest { createResolvedComponentsForTestWithOtherProfile(3); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen()) .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); @@ -716,11 +651,7 @@ public class UnbundledChooserActivityTest { createResolvedComponentsForTestWithOtherProfile(3); ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -745,15 +676,148 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) + public void testImagePlusTextSharing_ExcludeText() { + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT")) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse(); + } + + @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) + public void testImagePlusTextSharing_RemoveAndAddBackText() { + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + final String text = "https://google.com/search?q=google"; + sendIntent.putExtra(Intent.EXTRA_TEXT, text); + + List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + new Intent("VIEW_TEXT")) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + onView(withId(R.id.include_text_action)) + .perform(click()); + waitForIdle(); + + AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + + @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) + public void testImagePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); + + Intent alternativeIntent = createSendTextIntent(); + final String text = "alternative intent"; + alternativeIntent.putExtra(Intent.EXTRA_TEXT, text); + + List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.imageviewer", "ImageTarget"), + sendIntent), + ResolverDataProvider.createResolvedComponentInfo( + new ComponentName("org.textviewer", "UriTarget"), + alternativeIntent) + ); + + setupResolverControllers(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withId(R.id.include_text_action)) + .check(matches(isDisplayed())) + .perform(click()); + waitForIdle(); + + AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + launchedIntentRef.set(targetInfo.getTargetIntent()); + return true; + }; + + onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); + } + + @Test public void copyTextToClipboard() throws Exception { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final ChooserActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -777,11 +841,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -800,11 +860,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -830,11 +886,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -848,7 +900,7 @@ public class UnbundledChooserActivityTest { @Test - public void oneVisibleImagePreview() throws InterruptedException { + public void oneVisibleImagePreview() { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -861,30 +913,34 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_image_1_large)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_image_2_large)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_image_2_small)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_image_3_small)) - .check(matches(not(isDisplayed()))); + onView(withId(com.android.internal.R.id.content_preview_image_area)) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + ViewGroup parent = (ViewGroup) view; + ArrayList<View> visibleViews = new ArrayList<>(); + for (int i = 0, count = parent.getChildCount(); i < count; i++) { + View child = parent.getChildAt(i); + if (child.getVisibility() == View.VISIBLE) { + visibleViews.add(child); + } + } + assertThat(visibleViews.size(), is(1)); + assertThat( + "image preview view is fully visible", + isDisplayed().matches(visibleViews.get(0))); + }); } @Test - public void twoVisibleImagePreview() throws InterruptedException { + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, + values = { false }) + public void twoVisibleImagePreview() { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -898,16 +954,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_image_1_large)) @@ -921,7 +968,10 @@ public class UnbundledChooserActivityTest { } @Test - public void threeOrMoreVisibleImagePreview() throws InterruptedException { + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, + values = { false }) + public void threeOrMoreVisibleImagePreview() { Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.test320x240); @@ -938,16 +988,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_image_1_large)) @@ -961,6 +1002,72 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW_NAME }, + values = { true }) + public void testManyVisibleImagePreview_ScrollableImagePreview() { + Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withId(com.android.internal.R.id.content_preview_image_area)) + .perform(RecyclerViewActions.scrollToLastPosition()) + .check((view, exception) -> { + if (exception != null) { + throw exception; + } + RecyclerView recyclerView = (RecyclerView) view; + assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size())); + }); + } + + @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_IMAGE_AND_TEXT_PREVIEW_NAME }, + values = { true }) + public void testImageAndTextPreview() { + final Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + R.drawable.test320x240); + final String sharedText = "text-" + System.currentTimeMillis(); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + setupResolverControllers(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withText(sharedText)) + .check(matches(isDisplayed())); + } + + @Test public void testOnCreateLogging() { Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -1007,11 +1114,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -1036,16 +1139,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -1065,16 +1159,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_filename)) @@ -1099,16 +1184,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); onView(withId(com.android.internal.R.id.content_preview_filename)) @@ -1129,16 +1205,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); ChooserActivityOverrideData.getInstance().resolverForceException = true; @@ -1163,16 +1230,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); Cursor cursor = mock(Cursor.class); when(cursor.getCount()).thenReturn(1); @@ -1199,16 +1257,8 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); + when( ChooserActivityOverrideData .getInstance() @@ -1241,16 +1291,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = @@ -1331,16 +1372,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = @@ -1425,16 +1457,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = @@ -1509,16 +1532,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = @@ -1597,16 +1611,7 @@ public class UnbundledChooserActivityTest { // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // set caller-provided target Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); @@ -1665,6 +1670,91 @@ public class UnbundledChooserActivityTest { } @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_CUSTOM_ACTIONS_NAME }, + values = { true }) + public void testLaunchWithCustomAction() throws InterruptedException { + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); + final String customActionLabel = "Custom Action"; + final String testAction = "test-broadcast-receiver-action"; + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, + new ChooserAction[] { + new ChooserAction.Builder( + Icon.createWithResource("", Resources.ID_NULL), + customActionLabel, + PendingIntent.getBroadcast( + testContext, + 123, + new Intent(testAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)) + .build() + }); + // Start activity + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + final CountDownLatch broadcastInvoked = new CountDownLatch(1); + BroadcastReceiver testReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + broadcastInvoked.countDown(); + } + }; + testContext.registerReceiver(testReceiver, new IntentFilter(testAction)); + + try { + onView(withText(customActionLabel)).perform(click()); + broadcastInvoked.await(); + } finally { + testContext.unregisterReceiver(testReceiver); + } + } + + @Test + @RequireFeatureFlags( + flags = { Flags.SHARESHEET_RESELECTION_ACTION_NAME }, + values = { true }) + public void testLaunchWithShareModification() throws InterruptedException { + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); + final String modifyShareAction = "test-broadcast-receiver-action"; + Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); + chooserIntent.putExtra( + Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, + PendingIntent.getBroadcast( + testContext, + 123, + new Intent(modifyShareAction), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)); + // Start activity + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + + final CountDownLatch broadcastInvoked = new CountDownLatch(1); + BroadcastReceiver testReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + broadcastInvoked.countDown(); + } + }; + testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction)); + + try { + onView(withText(R.string.select_text)).perform(click()); + broadcastInvoked.await(); + } finally { + testContext.unregisterReceiver(testReceiver); + } + } + + @Test public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); givenAppTargets(/* appCount= */ 16); @@ -1715,16 +1805,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(15); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // Create direct share target List<ChooserTarget> serviceTargets = createDirectShareTargets(1, @@ -2004,16 +2085,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -2045,16 +2117,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // create test shortcut loader factory, remember loaders and their callbacks SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = @@ -2130,21 +2193,69 @@ public class UnbundledChooserActivityTest { /* selectionCost= */ anyLong()); } + @Test + public void testDirectTargetPinningDialog() { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + setupResolverControllers(resolvedComponentInfos); + + // create test shortcut loader factory, remember loaders and their callbacks + SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = + new SparseArray<>(); + ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = + (userHandle, callback) -> { + Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = + new Pair<>(mock(ShortcutLoader.class), callback); + shortcutLoaders.put(userHandle.getIdentifier(), pair); + return pair.first; + }; + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + // verify that ShortcutLoader was queried + ArgumentCaptor<DisplayResolveInfo[]> appTargets = + ArgumentCaptor.forClass(DisplayResolveInfo[].class); + verify(shortcutLoaders.get(0).first, times(1)) + .queryShortcuts(appTargets.capture()); + + // send shortcuts + List<ChooserTarget> serviceTargets = createDirectShareTargets( + 1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ShortcutLoader.Result result = new ShortcutLoader.Result( + // TODO: test another value as well + false, + appTargets.getValue(), + new ShortcutLoader.ShortcutResultInfo[] { + new ShortcutLoader.ShortcutResultInfo( + appTargets.getValue()[0], + serviceTargets + ) + }, + new HashMap<>(), + new HashMap<>() + ); + activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); + waitForIdle(); + + // Long-click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)).perform(longClick()); + waitForIdle(); + + onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed())); + } + @Test @Ignore public void testEmptyDirectRowLogging() throws InterruptedException { Intent sendIntent = createSendTextIntent(); // We need app targets for direct targets to get displayed List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); // Start activity final IChooserWrapper activity = (IChooserWrapper) @@ -2168,16 +2279,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendTextIntent(); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); @@ -2239,16 +2341,7 @@ public class UnbundledChooserActivityTest { public void testOneInitialIntent_noAutolaunch() { List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTest(1); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + setupResolverControllers(personalResolvedComponentInfos); Intent chooserIntent = createChooserIntent(createSendTextIntent(), new Intent[] {new Intent("action.fake")}); ResolveInfo[] chosen = new ResolveInfo[1]; @@ -2374,12 +2467,7 @@ public class UnbundledChooserActivityTest { // Create 4 ranked app targets. List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTest(4); - when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + setupResolverControllers(personalResolvedComponentInfos); // Create caller target which is duplicate with one of app targets Intent chooserIntent = createChooserIntent(createSendTextIntent(), new Intent[] {new Intent("action.fake")}); @@ -2646,28 +2734,35 @@ public class UnbundledChooserActivityTest { } private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos) { + setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); + } + + private void setupResolverControllers( List<ResolvedComponentInfo> personalResolvedComponentInfos, List<ResolvedComponentInfo> workResolvedComponentInfos) { when( ChooserActivityOverrideData .getInstance() .resolverListController - .getResolversForIntent( + .getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), - Mockito.isA(List.class))) + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); when( ChooserActivityOverrideData .getInstance() .workResolverListController - .getResolversForIntent( + .getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); when( ChooserActivityOverrideData .getInstance() @@ -2677,8 +2772,8 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + eq(UserHandle.of(10)))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); } private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { @@ -2717,16 +2812,7 @@ public class UnbundledChooserActivityTest { private void givenAppTargets(int appCount) { List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(appCount); - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntent( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(resolvedComponentInfos); + setupResolverControllers(resolvedComponentInfos); } private void updateMaxTargetsPerRowResource(int targetsPerRow) { diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java index f1febed2..87dc1b9d 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java @@ -48,7 +48,6 @@ import androidx.test.InstrumentationRegistry; import androidx.test.espresso.NoMatchingViewException; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; import com.android.internal.R; @@ -274,21 +273,27 @@ public class UnbundledChooserActivityWorkProfileTest { private void setupResolverControllers( List<ResolvedComponentInfo> personalResolvedComponentInfos, List<ResolvedComponentInfo> workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + when(sOverrides.resolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), - Mockito.isA(List.class))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(), Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), - Mockito.isA(List.class))).thenReturn(workResolvedComponentInfos); - when(sOverrides.workResolverListController.getResolversForIntentAsUser(Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when(sOverrides.workResolverListController.getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(WORK_USER_HANDLE))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); } private void waitForIdle() { diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt new file mode 100644 index 00000000..e9c755d3 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt @@ -0,0 +1,497 @@ +/* + * 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 + *3 + * 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.chooser + +import android.app.Activity +import android.app.prediction.AppTarget +import android.app.prediction.AppTargetId +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ResolveInfo +import android.os.Bundle +import android.os.UserHandle +import com.android.intentresolver.createShortcutInfo +import com.android.intentresolver.mock +import com.android.intentresolver.ResolverActivity +import com.android.intentresolver.ResolverDataProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ImmutableTargetInfoTest { + private val resolvedIntent = Intent("resolved") + private val targetIntent = Intent("target") + private val referrerFillInIntent = Intent("referrer_fillin") + private val resolvedComponentName = ComponentName("resolved", "component") + private val chooserTargetComponentName = ComponentName("chooser", "target") + private val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0) + private val displayLabel: CharSequence = "Display Label" + private val extendedInfo: CharSequence = "Extended Info" + private val displayIconHolder: TargetInfo.IconHolder = mock() + private val sourceIntent1 = Intent("source1") + private val sourceIntent2 = Intent("source2") + private val displayTarget1 = DisplayResolveInfo.newDisplayResolveInfo( + Intent("display1"), + ResolverDataProvider.createResolveInfo(2, 0), + "display1 label", + "display1 extended info", + Intent("display1_resolved"), + /* resolveInfoPresentationGetter= */ null) + private val displayTarget2 = DisplayResolveInfo.newDisplayResolveInfo( + Intent("display2"), + ResolverDataProvider.createResolveInfo(3, 0), + "display2 label", + "display2 extended info", + Intent("display2_resolved"), + /* resolveInfoPresentationGetter= */ null) + private val directShareShortcutInfo = createShortcutInfo( + "shortcutid", ResolverDataProvider.createComponentName(4), 4) + private val directShareAppTarget = AppTarget( + AppTargetId("apptargetid"), + "test.directshare", + "target", + UserHandle.CURRENT) + private val displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( + Intent("displayresolve"), + ResolverDataProvider.createResolveInfo(5, 0), + "displayresolve label", + "displayresolve extended info", + Intent("display_resolved"), + /* resolveInfoPresentationGetter= */ null) + private val hashProvider: ImmutableTargetInfo.TargetHashProvider = mock() + + @Test + fun testBasicProperties() { // Fields that are reflected back w/o logic. + // TODO: we could consider passing copies of all the values into the builder so that we can + // verify that they're not mutated (e.g. no extras added to the intents). For now that + // should be obvious from the implementation. + val info = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(resolvedIntent) + .setTargetIntent(targetIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .setResolvedComponentName(resolvedComponentName) + .setChooserTargetComponentName(chooserTargetComponentName) + .setResolveInfo(resolveInfo) + .setDisplayLabel(displayLabel) + .setExtendedInfo(extendedInfo) + .setDisplayIconHolder(displayIconHolder) + .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) + .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) + .setIsSuspended(true) + .setIsPinned(true) + .setModifiedScore(42.0f) + .setDirectShareShortcutInfo(directShareShortcutInfo) + .setDirectShareAppTarget(directShareAppTarget) + .setDisplayResolveInfo(displayResolveInfo) + .setHashProvider(hashProvider) + .build() + + assertThat(info.resolvedIntent).isEqualTo(resolvedIntent) + assertThat(info.targetIntent).isEqualTo(targetIntent) + assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent) + assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName) + assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName) + assertThat(info.resolveInfo).isEqualTo(resolveInfo) + assertThat(info.displayLabel).isEqualTo(displayLabel) + assertThat(info.extendedInfo).isEqualTo(extendedInfo) + assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) + assertThat(info.allSourceIntents).containsExactly( + resolvedIntent, sourceIntent1, sourceIntent2) + assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) + assertThat(info.isSuspended).isTrue() + assertThat(info.isPinned).isTrue() + assertThat(info.modifiedScore).isEqualTo(42.0f) + assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo) + assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget) + assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo) + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isFalse() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + assertThat(info.hashProvider).isEqualTo(hashProvider) + } + + @Test + fun testToBuilderPreservesBasicProperties() { + // Note this is set up exactly as in `testBasicProperties`, but the assertions will be made + // against a *copy* of the object instead. + val infoToCopyFrom = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(resolvedIntent) + .setTargetIntent(targetIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .setResolvedComponentName(resolvedComponentName) + .setChooserTargetComponentName(chooserTargetComponentName) + .setResolveInfo(resolveInfo) + .setDisplayLabel(displayLabel) + .setExtendedInfo(extendedInfo) + .setDisplayIconHolder(displayIconHolder) + .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) + .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) + .setIsSuspended(true) + .setIsPinned(true) + .setModifiedScore(42.0f) + .setDirectShareShortcutInfo(directShareShortcutInfo) + .setDirectShareAppTarget(directShareAppTarget) + .setDisplayResolveInfo(displayResolveInfo) + .setHashProvider(hashProvider) + .build() + + val info = infoToCopyFrom.toBuilder().build() + + assertThat(info.resolvedIntent).isEqualTo(resolvedIntent) + assertThat(info.targetIntent).isEqualTo(targetIntent) + assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent) + assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName) + assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName) + assertThat(info.resolveInfo).isEqualTo(resolveInfo) + assertThat(info.displayLabel).isEqualTo(displayLabel) + assertThat(info.extendedInfo).isEqualTo(extendedInfo) + assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) + assertThat(info.allSourceIntents).containsExactly( + resolvedIntent, sourceIntent1, sourceIntent2) + assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) + assertThat(info.isSuspended).isTrue() + assertThat(info.isPinned).isTrue() + assertThat(info.modifiedScore).isEqualTo(42.0f) + assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo) + assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget) + assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo) + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isFalse() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + assertThat(info.hashProvider).isEqualTo(hashProvider) + } + + @Test + fun testBaseIntentToSend_defaultsToResolvedIntent() { + val info = ImmutableTargetInfo.newBuilder().setResolvedIntent(resolvedIntent).build() + assertThat(info.baseIntentToSend.filterEquals(resolvedIntent)).isTrue() + } + + @Test + fun testBaseIntentToSend_fillsInFromReferrerIntent() { + val originalIntent = Intent() + originalIntent.setPackage("original") + + val referrerFillInIntent = Intent("REFERRER_FILL_IN") + referrerFillInIntent.setPackage("referrer") + + val info = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .build() + + assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty. + assertThat(info.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN") + } + + @Test + fun testBaseIntentToSend_fillsInFromRefinementIntent() { + val originalIntent = Intent() + originalIntent.putExtra("ORIGINAL", true) + + val refinementIntent = Intent() + refinementIntent.putExtra("REFINEMENT", true) + + val originalInfo = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .build() + val info = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent) + + assertThat(info.baseIntentToSend.getBooleanExtra("ORIGINAL", false)).isTrue() + assertThat(info.baseIntentToSend.getBooleanExtra("REFINEMENT", false)).isTrue() + } + + @Test + fun testBaseIntentToSend_twoFillInSourcesFavorsRefinementRequest() { + val originalIntent = Intent("REFINE_ME") + originalIntent.setPackage("original") + + val referrerFillInIntent = Intent("REFERRER_FILL_IN") + referrerFillInIntent.setPackage("referrer_pkg") + referrerFillInIntent.setType("test/referrer") + + val infoWithReferrerFillIn = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .build() + + val refinementIntent = Intent("REFINE_ME") + refinementIntent.setPackage("original") // Has to match for refinement. + + val info = infoWithReferrerFillIn.tryToCloneWithAppliedRefinement(refinementIntent) + + assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Set all along. + assertThat(info.baseIntentToSend.action).isEqualTo("REFINE_ME") // Refinement wins. + assertThat(info.baseIntentToSend.type).isEqualTo("test/referrer") // Left for referrer. + } + + @Test + fun testBaseIntentToSend_doubleRefinementPreservesReferrerFillInButNotOriginalRefinement() { + val originalIntent = Intent("REFINE_ME") + val referrerFillInIntent = Intent("REFERRER_FILL_IN") + referrerFillInIntent.putExtra("TEST", "REFERRER") + val refinementIntent1 = Intent("REFINE_ME") + refinementIntent1.putExtra("TEST1", "1") + val refinementIntent2 = Intent("REFINE_ME") + refinementIntent2.putExtra("TEST2", "2") + + val originalInfo = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .build() + + val refined1 = originalInfo.tryToCloneWithAppliedRefinement(refinementIntent1) + val refined2 = refined1.tryToCloneWithAppliedRefinement(refinementIntent2) // Cloned clone. + + // Both clones get the same values filled in from the referrer intent. + assertThat(refined1.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER") + assertThat(refined2.baseIntentToSend.getStringExtra("TEST")).isEqualTo("REFERRER") + // Each clone has the respective value that was set in their own refinement request. + assertThat(refined1.baseIntentToSend.getStringExtra("TEST1")).isEqualTo("1") + assertThat(refined2.baseIntentToSend.getStringExtra("TEST2")).isEqualTo("2") + // The clones don't have the data from each other's refinements, even though the intent + // field is empty (thus able to be populated by filling-in). + assertThat(refined1.baseIntentToSend.getStringExtra("TEST2")).isNull() + assertThat(refined2.baseIntentToSend.getStringExtra("TEST1")).isNull() + } + + @Test + fun testBaseIntentToSend_refinementToAlternateSourceIntent() { + val originalIntent = Intent("DONT_REFINE_ME") + originalIntent.putExtra("originalIntent", true) + val mismatchedAlternate = Intent("DOESNT_MATCH") + mismatchedAlternate.putExtra("mismatchedAlternate", true) + val targetAlternate = Intent("REFINE_ME") + targetAlternate.putExtra("targetAlternate", true) + val extraMatch = Intent("REFINE_ME") + extraMatch.putExtra("extraMatch", true) + + val originalInfo = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setAllSourceIntents(listOf( + originalIntent, mismatchedAlternate, targetAlternate, extraMatch)) + .build() + + val refinement = Intent("REFINE_ME") // First match is `targetAlternate` + refinement.putExtra("refinement", true) + + val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement) + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("refinement", false)).isTrue() + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("targetAlternate", false)) + .isTrue() + // None of the other source intents got merged in (not even the later one that matched): + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("originalIntent", false)) + .isFalse() + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("mismatchedAlternate", false)) + .isFalse() + assertThat(refinedResult.baseIntentToSend.getBooleanExtra("extraMatch", false)).isFalse() + } + + @Test + fun testBaseIntentToSend_noSourceIntentMatchingProposedRefinement() { + val originalIntent = Intent("DONT_REFINE_ME") + originalIntent.putExtra("originalIntent", true) + val mismatchedAlternate = Intent("DOESNT_MATCH") + mismatchedAlternate.putExtra("mismatchedAlternate", true) + + val originalInfo = ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setAllSourceIntents(listOf(originalIntent, mismatchedAlternate)) + .build() + + val refinement = Intent("PROPOSED_REFINEMENT") + assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() + } + + @Test + fun testLegacySubclassRelationships_empty() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isTrue() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isTrue() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isTrue() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + } + + @Test + fun testLegacySubclassRelationships_placeholder() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isTrue() + assertThat(info.isNotSelectableTargetInfo).isTrue() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isTrue() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + } + + @Test + fun testLegacySubclassRelationships_selectable() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.SELECTABLE_TARGET_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isTrue() + assertThat(info.isChooserTargetInfo).isTrue() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isFalse() + } + + @Test + fun testLegacySubclassRelationships_displayResolveInfo() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.DISPLAY_RESOLVE_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isFalse() + assertThat(info.isMultiDisplayResolveInfo).isFalse() + assertThat(info.isDisplayResolveInfo).isTrue() + } + + @Test + fun testLegacySubclassRelationships_multiDisplayResolveInfo() { + val info = ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO) + .build() + + assertThat(info.isEmptyTargetInfo).isFalse() + assertThat(info.isPlaceHolderTargetInfo).isFalse() + assertThat(info.isNotSelectableTargetInfo).isFalse() + assertThat(info.isSelectableTargetInfo).isFalse() + assertThat(info.isChooserTargetInfo).isFalse() + assertThat(info.isMultiDisplayResolveInfo).isTrue() + assertThat(info.isDisplayResolveInfo).isTrue() + } + + @Test + fun testActivityStarter_correctNumberOfInvocations_startAsCaller() { + val activityStarter = object : TestActivityStarter() { + override fun startAsUser( + target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle + ): Boolean { + throw RuntimeException("Wrong API used: startAsUser") + } + } + + val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() + val activity: ResolverActivity = mock() + val options = Bundle() + options.putInt("TEST_KEY", 1) + + info.startAsCaller(activity, options, 42) + + assertThat(activityStarter.totalInvocations).isEqualTo(1) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info) + assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity) + assertThat(activityStarter.lastInvocationOptions).isEqualTo(options) + assertThat(activityStarter.lastInvocationUserId).isEqualTo(42) + assertThat(activityStarter.lastInvocationAsCaller).isTrue() + } + + @Test + fun testActivityStarter_correctNumberOfInvocations_startAsUser() { + val activityStarter = object : TestActivityStarter() { + override fun startAsCaller( + target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { + throw RuntimeException("Wrong API used: startAsCaller") + } + } + + val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() + val activity: Activity = mock() + val options = Bundle() + options.putInt("TEST_KEY", 1) + + info.startAsUser(activity, options, UserHandle.of(42)) + + assertThat(activityStarter.totalInvocations).isEqualTo(1) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info) + assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity) + assertThat(activityStarter.lastInvocationOptions).isEqualTo(options) + assertThat(activityStarter.lastInvocationUserId).isEqualTo(42) + assertThat(activityStarter.lastInvocationAsCaller).isFalse() + } + + @Test + fun testActivityStarter_invokedWithRespectiveTargetInfoAfterCopy() { + val activityStarter = TestActivityStarter() + val info1 = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() + val info2 = info1.toBuilder().build() + + info1.startAsCaller(mock(), Bundle(), 42) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info1) + info2.startAsCaller(mock(), Bundle(), 42) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) + info2.startAsUser(mock(), Bundle(), UserHandle.of(42)) + assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) + + assertThat(activityStarter.totalInvocations).isEqualTo(3) // Instance is still shared. + } +} + +private open class TestActivityStarter : ImmutableTargetInfo.TargetActivityStarter { + var totalInvocations = 0 + var lastInvocationTargetInfo: TargetInfo? = null + var lastInvocationActivity: Activity? = null + var lastInvocationOptions: Bundle? = null + var lastInvocationUserId: Integer? = null + var lastInvocationAsCaller = false + + override fun startAsCaller( + target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { + ++totalInvocations + lastInvocationTargetInfo = target + lastInvocationActivity = activity + lastInvocationOptions = options + lastInvocationUserId = Integer(userId) + lastInvocationAsCaller = true + return true + } + + override fun startAsUser( + target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle): Boolean { + ++totalInvocations + lastInvocationTargetInfo = target + lastInvocationActivity = activity + lastInvocationOptions = options + lastInvocationUserId = Integer(user.identifier) + lastInvocationAsCaller = false + return true + } +} diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt index 7c2b07a9..dddbcccb 100644 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -22,18 +22,36 @@ import android.content.ComponentName import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.ResolveInfo +import android.graphics.drawable.AnimatedVectorDrawable import android.os.UserHandle +import android.test.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.ResolverDataProvider import com.android.intentresolver.createChooserTarget import com.android.intentresolver.createShortcutInfo import com.android.intentresolver.mock -import com.android.intentresolver.ResolverDataProvider +import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat +import org.junit.Before import org.junit.Test +import org.mockito.Mockito.any +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify class TargetInfoTest { private val context = InstrumentationRegistry.getInstrumentation().getContext() + @Before + fun setup() { + // SelectableTargetInfo reads DeviceConfig and needs a permission for that. + InstrumentationRegistry + .getInstrumentation() + .getUiAutomation() + .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG") + } + @Test fun testNewEmptyTargetInfo() { val info = NotSelectableTargetInfo.newEmptyTargetInfo() @@ -43,13 +61,19 @@ class TargetInfoTest { assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull() } + @UiThreadTest // AnimatedVectorDrawable needs to start from a thread with a Looper. @Test fun testNewPlaceholderTargetInfo() { val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context) - assertThat(info.isPlaceHolderTargetInfo()).isTrue() - assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. + assertThat(info.isPlaceHolderTargetInfo).isTrue() + assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model. assertThat(info.hasDisplayIcon()).isTrue() - // TODO: test infrastructure isn't set up to assert anything about the icon itself. + assertThat(info.displayIconHolder.displayIcon) + .isInstanceOf(AnimatedVectorDrawable::class.java) + // TODO: assert that the animation is pre-started/running (IIUC this requires synchronizing + // with some "render thread" per the `AnimatedVectorDrawable` docs). I believe this is + // possible using `AnimatorTestRule` but I couldn't find any sample usage in Kotlin nor get + // it working myself. } @Test @@ -125,6 +149,42 @@ class TargetInfoTest { } @Test + fun testSelectableTargetInfo_noSourceIntentMatchingProposedRefinement() { + val resolvedIntent = Intent("DONT_REFINE_ME") + resolvedIntent.putExtra("resolvedIntent", true) + + val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( + resolvedIntent, + ResolverDataProvider.createResolveInfo(1, 0), + "label", + "extended info", + resolvedIntent, + /* resolveInfoPresentationGetter= */ null) + val chooserTarget = createChooserTarget( + "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") + val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) + val appTarget = AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT) + + val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( + baseDisplayInfo, + mock(), + resolvedIntent, + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) + + val refinement = Intent("PROPOSED_REFINEMENT") + assertThat(targetInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() + } + + @Test fun testNewDisplayResolveInfo() { val intent = Intent(Intent.ACTION_SEND) intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") @@ -145,6 +205,64 @@ class TargetInfoTest { } @Test + fun test_DisplayResolveInfo_refinementToAlternateSourceIntent() { + val originalIntent = Intent("DONT_REFINE_ME") + originalIntent.putExtra("originalIntent", true) + val mismatchedAlternate = Intent("DOESNT_MATCH") + mismatchedAlternate.putExtra("mismatchedAlternate", true) + val targetAlternate = Intent("REFINE_ME") + targetAlternate.putExtra("targetAlternate", true) + val extraMatch = Intent("REFINE_ME") + extraMatch.putExtra("extraMatch", true) + + val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ResolverDataProvider.createResolveInfo(3, 0), + "label", + "extended info", + originalIntent, + /* resolveInfoPresentationGetter= */ null) + originalInfo.addAlternateSourceIntent(mismatchedAlternate) + originalInfo.addAlternateSourceIntent(targetAlternate) + originalInfo.addAlternateSourceIntent(extraMatch) + + val refinement = Intent("REFINE_ME") // First match is `targetAlternate` + refinement.putExtra("refinement", true) + + val refinedResult = originalInfo.tryToCloneWithAppliedRefinement(refinement) + // Note `DisplayResolveInfo` targets merge refinements directly into their `resolvedIntent`. + assertThat(refinedResult.resolvedIntent.getBooleanExtra("refinement", false)).isTrue() + assertThat(refinedResult.resolvedIntent.getBooleanExtra("targetAlternate", false)) + .isTrue() + // None of the other source intents got merged in (not even the later one that matched): + assertThat(refinedResult.resolvedIntent.getBooleanExtra("originalIntent", false)) + .isFalse() + assertThat(refinedResult.resolvedIntent.getBooleanExtra("mismatchedAlternate", false)) + .isFalse() + assertThat(refinedResult.resolvedIntent.getBooleanExtra("extraMatch", false)).isFalse() + } + + @Test + fun testDisplayResolveInfo_noSourceIntentMatchingProposedRefinement() { + val originalIntent = Intent("DONT_REFINE_ME") + originalIntent.putExtra("originalIntent", true) + val mismatchedAlternate = Intent("DOESNT_MATCH") + mismatchedAlternate.putExtra("mismatchedAlternate", true) + + val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + ResolverDataProvider.createResolveInfo(3, 0), + "label", + "extended info", + originalIntent, + /* resolveInfoPresentationGetter= */ null) + originalInfo.addAlternateSourceIntent(mismatchedAlternate) + + val refinement = Intent("PROPOSED_REFINEMENT") + assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() + } + + @Test fun testNewMultiDisplayResolveInfo() { val intent = Intent(Intent.ACTION_SEND) intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") @@ -186,7 +304,93 @@ class TargetInfoTest { assertThat(multiTargetInfo.hasSelected()).isTrue() assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo) + val refined = multiTargetInfo.tryToCloneWithAppliedRefinement(intent) + assertThat(refined).isInstanceOf(MultiDisplayResolveInfo::class.java) + assertThat((refined as MultiDisplayResolveInfo).hasSelected()) + .isEqualTo(multiTargetInfo.hasSelected()) + // TODO: consider exercising activity-start behavior. // TODO: consider exercising DisplayResolveInfo base class behavior. } + + @Test + fun testNewMultiDisplayResolveInfo_getAllSourceIntents_fromSelectedTarget() { + val sendImage = Intent("SEND").apply { type = "image/png" } + val sendUri = Intent("SEND").apply { type = "text/uri" } + + val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0) + + val imageOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + resolveInfo, + "Send Image", + "Sends only images", + sendImage, + /* resolveInfoPresentationGetter= */ null) + + val textOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( + sendUri, + resolveInfo, + "Send Text", + "Sends only text", + sendUri, + /* resolveInfoPresentationGetter= */ null) + + val imageOrTextTarget = DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + resolveInfo, + "Send Image or Text", + "Sends images or text", + sendImage, + /* resolveInfoPresentationGetter= */ null + ).apply { + addAlternateSourceIntent(sendUri) + } + + val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget) + ) + + multiTargetInfo.setSelected(0) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOnlyTarget) + assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOnlyTarget.allSourceIntents) + + multiTargetInfo.setSelected(1) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(textOnlyTarget) + assertThat(multiTargetInfo.allSourceIntents).isEqualTo(textOnlyTarget.allSourceIntents) + + multiTargetInfo.setSelected(2) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOrTextTarget) + assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOrTextTarget.allSourceIntents) + } + + @Test + fun testNewMultiDisplayResolveInfo_tryToCloneWithAppliedRefinement_delegatedToSelectedTarget() { + val refined = Intent("SEND") + val sendImage = Intent("SEND") + val targetOne = spy( + DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + ResolverDataProvider.createResolveInfo(1, 0), + "Target One", + "Target One", + sendImage, + /* resolveInfoPresentationGetter= */ null + ) + ) + val targetTwo = mock<DisplayResolveInfo> { + whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this) + } + + val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + listOf(targetOne, targetTwo) + ) + + multiTargetInfo.setSelected(1) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(targetTwo) + + multiTargetInfo.tryToCloneWithAppliedRefinement(refined) + verify(targetTwo, times(1)).tryToCloneWithAppliedRefinement(refined) + verify(targetOne, never()).tryToCloneWithAppliedRefinement(any()) + } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt new file mode 100644 index 00000000..d870a8c2 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.ClipDescription +import android.content.ContentInterface +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import com.android.intentresolver.ImageLoader +import com.android.intentresolver.TestFeatureFlagRepository +import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory +import com.android.intentresolver.flags.Flags +import com.android.intentresolver.mock +import com.android.intentresolver.whenever +import com.android.intentresolver.widget.ActionRow +import com.android.intentresolver.widget.ImagePreviewView +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import java.util.function.Consumer + +private const val PROVIDER_NAME = "org.pkg.app" +class ChooserContentPreviewUiTest { + private val contentResolver = mock<ContentInterface>() + private val imageClassifier = ChooserContentPreviewUi.ImageMimeTypeClassifier { mimeType -> + mimeType != null && ClipDescription.compareMimeTypes(mimeType, "image/*") + } + private val imageLoader = object : ImageLoader { + override fun loadImage(uri: Uri, callback: Consumer<Bitmap?>) { + callback.accept(null) + } + override fun prePopulate(uris: List<Uri>) = Unit + override suspend fun invoke(uri: Uri): Bitmap? = null + } + private val actionFactory = object : ActionFactory { + override fun createCopyButton() = ActionRow.Action(label = "Copy", icon = null) {} + override fun createEditButton(): ActionRow.Action? = null + override fun createNearbyButton(): ActionRow.Action? = null + override fun createCustomActions(): List<ActionRow.Action> = emptyList() + override fun getModifyShareAction(): Runnable? = null + override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {} + } + private val transitionCallback = mock<ImagePreviewView.TransitionElementStatusCallback>() + private val featureFlagRepository = TestFeatureFlagRepository( + mapOf( + Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW to true + ) + ) + + @Test + fun test_ChooserContentPreview_non_send_intent_action_to_text_preview() { + val targetIntent = Intent(Intent.ACTION_VIEW) + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_text_mime_type_to_text_preview() { + val targetIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "Text Extra") + } + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_single_image_uri_to_image_preview() { + val uri = Uri.parse("content://$PROVIDER_NAME/test.png") + val targetIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("image/png") + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + verify(transitionCallback, never()).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_single_non_image_uri_to_file_preview() { + val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_multiple_image_uri_to_image_preview() { + val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png") + val uri2 = Uri.parse("content://$PROVIDER_NAME/test.jpg") + val targetIntent = 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 = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + verify(transitionCallback, never()).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_some_non_image_uri_to_file_preview() { + val uri1 = Uri.parse("content://$PROVIDER_NAME/test.png") + val uri2 = Uri.parse("content://$PROVIDER_NAME/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("image/png") + whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } +} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 448718cd..006f3b2d 100644 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -27,7 +27,7 @@ import android.os.Message; import androidx.test.InstrumentationRegistry; -import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.ResolvedComponentInfo; import org.junit.Test; @@ -37,12 +37,12 @@ public class AbstractResolverComparatorTest { @Test public void testPinned() { - ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( + ResolvedComponentInfo r1 = new ResolvedComponentInfo( new ComponentName("package", "class"), new Intent(), new ResolveInfo() ); r1.setPinned(true); - ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( + ResolvedComponentInfo r2 = new ResolvedComponentInfo( new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo() ); @@ -60,14 +60,14 @@ public class AbstractResolverComparatorTest { pmInfo1.activityInfo = new ActivityInfo(); pmInfo1.activityInfo.packageName = "aaa"; - ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo( + ResolvedComponentInfo r1 = new ResolvedComponentInfo( new ComponentName("package", "class"), new Intent(), pmInfo1); r1.setPinned(true); ResolveInfo pmInfo2 = new ResolveInfo(); pmInfo2.activityInfo = new ActivityInfo(); pmInfo2.activityInfo.packageName = "zzz"; - ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo( + ResolvedComponentInfo r2 = new ResolvedComponentInfo( new ComponentName("zackage", "zlass"), new Intent(), pmInfo2); r2.setPinned(true); @@ -91,7 +91,7 @@ public class AbstractResolverComparatorTest { } @Override - void doCompute(List<ResolverActivity.ResolvedComponentInfo> targets) {} + void doCompute(List<ResolvedComponentInfo> targets) {} @Override public float getScore(ComponentName name) { diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index 5756a0cd..0c817cb2 100644 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -28,6 +28,8 @@ import android.os.UserHandle import android.os.UserManager import androidx.test.filters.SmallTest import com.android.intentresolver.any +import com.android.intentresolver.argumentCaptor +import com.android.intentresolver.capture import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.createAppTarget import com.android.intentresolver.createShareShortcutInfo @@ -39,8 +41,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import org.mockito.ArgumentCaptor import org.mockito.Mockito.anyInt +import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify @@ -56,9 +58,15 @@ class ShortcutLoaderTest { private val pm = mock<PackageManager> { whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo) } + 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 executor = ImmediateExecutor() private val intentFilter = mock<IntentFilter>() @@ -66,7 +74,7 @@ class ShortcutLoaderTest { private val callback = mock<Consumer<ShortcutLoader.Result>>() @Test - fun test_app_predictor_result() { + fun test_queryShortcuts_result_consistency_with_AppPredictor() { val componentName = ComponentName("pkg", "Class") val appTarget = mock<DisplayResolveInfo> { whenever(resolvedComponentName).thenReturn(componentName) @@ -85,24 +93,22 @@ class ShortcutLoaderTest { testSubject.queryShortcuts(appTargets) - verify(appPredictor, times(1)).requestPredictionUpdate() - val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) - verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) - val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) val matchingAppTarget = createAppTarget(matchingShortcutInfo) val shortcuts = listOf( matchingAppTarget, - // mismatching shortcut + // 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)) appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts) - val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) - verify(callback, times(1)).accept(resultCaptor.capture()) + val resultCaptor = argumentCaptor<ShortcutLoader.Result>() + verify(callback, times(1)).accept(capture(resultCaptor)) val result = resultCaptor.value assertTrue("An app predictor result is expected", result.isFromAppPredictor) @@ -124,7 +130,7 @@ class ShortcutLoaderTest { } @Test - fun test_shortcut_manager_result() { + fun test_queryShortcuts_result_consistency_with_ShortcutManager() { val componentName = ComponentName("pkg", "Class") val appTarget = mock<DisplayResolveInfo> { whenever(resolvedComponentName).thenReturn(componentName) @@ -153,8 +159,8 @@ class ShortcutLoaderTest { testSubject.queryShortcuts(appTargets) - val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) - verify(callback, times(1)).accept(resultCaptor.capture()) + val resultCaptor = argumentCaptor<ShortcutLoader.Result>() + verify(callback, times(1)).accept(capture(resultCaptor)) val result = resultCaptor.value assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) @@ -175,7 +181,7 @@ class ShortcutLoaderTest { } @Test - fun test_fallback_to_shortcut_manager() { + fun test_queryShortcuts_falls_back_to_ShortcutManager_on_empty_reply() { val componentName = ComponentName("pkg", "Class") val appTarget = mock<DisplayResolveInfo> { whenever(resolvedComponentName).thenReturn(componentName) @@ -205,13 +211,13 @@ class ShortcutLoaderTest { testSubject.queryShortcuts(appTargets) verify(appPredictor, times(1)).requestPredictionUpdate() - val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java) + val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) - val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java) - verify(callback, times(1)).accept(resultCaptor.capture()) + val resultCaptor = argumentCaptor<ShortcutLoader.Result>() + verify(callback, times(1)).accept(capture(resultCaptor)) val result = resultCaptor.value assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) @@ -232,32 +238,32 @@ class ShortcutLoaderTest { } @Test - fun test_do_not_call_services_for_not_running_work_profile() { + fun test_queryShortcuts_do_not_call_services_for_not_running_work_profile() { testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) } @Test - fun test_do_not_call_services_for_locked_work_profile() { + fun test_queryShortcuts_do_not_call_services_for_locked_work_profile() { testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false) } @Test - fun test_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() { + fun test_queryShortcuts_do_not_call_services_if_quite_mode_is_enabled_for_work_profile() { testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true) } @Test - fun test_call_services_for_not_running_main_profile() { + fun test_queryShortcuts_call_services_for_not_running_main_profile() { testAlwaysCallSystemForMainProfile(isUserRunning = false) } @Test - fun test_call_services_for_locked_main_profile() { + fun test_queryShortcuts_call_services_for_locked_main_profile() { testAlwaysCallSystemForMainProfile(isUserUnlocked = false) } @Test - fun test_call_services_if_quite_mode_is_enabled_for_main_profile() { + fun test_queryShortcuts_call_services_if_quite_mode_is_enabled_for_main_profile() { testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true) } @@ -267,7 +273,7 @@ class ShortcutLoaderTest { isQuietModeEnabled: Boolean = false ) { val userHandle = UserHandle.of(10) - val userManager = mock<UserManager> { + with(userManager) { whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) @@ -297,7 +303,7 @@ class ShortcutLoaderTest { isQuietModeEnabled: Boolean = false ) { val userHandle = UserHandle.of(10) - val userManager = mock<UserManager> { + with(userManager) { whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) |