Merge tag 'android-13.0.0_r32' into int/13/fp3
Android 13.0.0 release 32
* tag 'android-13.0.0_r32': (67 commits)
Move EnterTransitionAnimationDelegate out of ChooserActivity
ResolverDrawerLayout: unify mCollapsibleHeight calculation.
Introduce an image preview view
Add a mechanism to switch between action row view varians
[Chooser/ResolverActivity] Fix flakiness in work profile tests
A base scrollable action row implementation
Split ActionRow view into internface and implementation
Extract TargetPresentationGetter hierarchy
Create an action row view
Introduce GenericMultiProfilePagerAdapter
Extract remainining ChooserActivity logging
Extract ChooserGridAdapter.
SelectableTargetInfo "immutability"/other cleanup
Load app icons defensively
Fix ChooserActivity crash when launched with a caller-provided target
Fix flakey unit test
Fix NPE from dependency initialization order
Tighten visibility in the -ListAdapter classes.
Pre-work to decouple ChooserGridAdapter
Fail startup via ResolverActivity.super_onCreate()
...
Change-Id: I5b5e9a6e7ae5343cf60c2947953cc3c01d13c209
diff --git a/Android.bp b/Android.bp
index 2407fc7..31d7d6d 100644
--- a/Android.bp
+++ b/Android.bp
@@ -36,6 +36,7 @@
min_sdk_version: "current",
srcs: [
"java/src/**/*.java",
+ "java/src/**/*.kt",
],
resource_dirs: [
"java/res",
@@ -45,7 +46,18 @@
static_libs: [
"androidx.annotation_annotation",
- "unsupportedappusage",
+ "androidx.concurrent_concurrent-futures",
+ "androidx.recyclerview_recyclerview",
+ "androidx.viewpager_viewpager",
+ "androidx.lifecycle_lifecycle-common-java8",
+ "androidx.lifecycle_lifecycle-extensions",
+ "androidx.lifecycle_lifecycle-runtime-ktx",
+ "androidx.lifecycle_lifecycle-viewmodel-ktx",
+ "kotlin-stdlib",
+ "kotlinx_coroutines",
+ "kotlinx-coroutines-android",
+ "//external/kotlinc:kotlin-annotations",
+ "guava",
],
plugins: ["java_api_finder"],
@@ -68,10 +80,7 @@
"IntentResolver-core",
],
optimize: {
- // TODO: consider re-enabling after setting up Proguard rules to
- // preserve the name of the ChooserGridLayoutManager class, which is
- // referenced by name in the chooser_list_per_profile layout XML.
- enabled: false,
+ enabled: true,
},
apex_available: [
"//apex_available:platform",
diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml
index ea75611..620ff70 100644
--- a/java/res/layout/chooser_action_row.xml
+++ b/java/res/layout/chooser_action_row.xml
@@ -13,14 +13,20 @@
~ See the License for the specific language governing permissions and
~ limitations under the License
-->
-
-<LinearLayout
+<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingLeft="@dimen/chooser_edge_margin_normal"
- android:paddingRight="@dimen/chooser_edge_margin_normal"
- android:gravity="center"
- >
+ android:layout_height="wrap_content">
-</LinearLayout>
+ <com.android.intentresolver.widget.ChooserActionRow
+ android:id="@androidprv:id/chooser_action_row"
+ android:layout_width="@dimen/chooser_preview_width"
+ android:layout_height="wrap_content"
+ android:paddingLeft="@dimen/chooser_edge_margin_normal"
+ android:paddingRight="@dimen/chooser_edge_margin_normal"
+ android:layout_marginBottom="@dimen/chooser_view_spacing"
+ android:layout_gravity="center_horizontal"
+ android:gravity="center" />
+
+</FrameLayout>
\ No newline at end of file
diff --git a/java/res/layout/chooser_action_view.xml b/java/res/layout/chooser_action_view.xml
new file mode 100644
index 0000000..d74798e
--- /dev/null
+++ b/java/res/layout/chooser_action_view.xml
@@ -0,0 +1,28 @@
+<!--
+ ~ Copyright (C) 2019 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
+ -->
+
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:gravity="center"
+ android:drawablePadding="8dp"
+ android:textColor="?android:attr/textColorPrimary"
+ android:textSize="12sp"
+ android:maxWidth="192dp"
+ android:singleLine="true"
+ android:clickable="true"
+ android:drawableTint="?android:attr/textColorPrimary"
+ android:drawableTintMode="src_in"
+ style="?android:attr/borderlessButtonStyle"
+ />
diff --git a/java/res/layout/chooser_dialog.xml b/java/res/layout/chooser_dialog.xml
index ff66bbb..e31712c 100644
--- a/java/res/layout/chooser_dialog.xml
+++ b/java/res/layout/chooser_dialog.xml
@@ -50,9 +50,9 @@
</LinearLayout>
- <com.android.internal.widget.RecyclerView
+ <androidx.recyclerview.widget.RecyclerView
xmlns:app="http://schemas.android.com/apk/res-auto"
- androidprv:layoutManager="com.android.internal.widget.LinearLayoutManager"
+ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:id="@androidprv:id/listContainer"
android:overScrollMode="never"
android:layout_width="match_parent"
diff --git a/java/res/layout/chooser_grid.xml b/java/res/layout/chooser_grid.xml
index a95b0eb..d863495 100644
--- a/java/res/layout/chooser_grid.xml
+++ b/java/res/layout/chooser_grid.xml
@@ -16,14 +16,15 @@
* limitations under the License.
*/
-->
-<com.android.internal.widget.ResolverDrawerLayout
+<com.android.intentresolver.widget.ResolverDrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
- androidprv:maxCollapsedHeight="0dp"
- androidprv:maxCollapsedHeightSmall="56dp"
+ app:maxCollapsedHeight="0dp"
+ app:maxCollapsedHeightSmall="56dp"
android:maxWidth="@dimen/chooser_width"
android:id="@androidprv:id/contentPanel">
@@ -31,7 +32,7 @@
android:id="@androidprv:id/chooser_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:elevation="0dp"
android:background="@drawable/bottomsheet_background">
@@ -94,4 +95,4 @@
</LinearLayout>
</TabHost>
-</com.android.internal.widget.ResolverDrawerLayout>
+</com.android.intentresolver.widget.ResolverDrawerLayout>
diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml
index 6d2e76a..e98c327 100644
--- a/java/res/layout/chooser_grid_preview_file.xml
+++ b/java/res/layout/chooser_grid_preview_file.xml
@@ -37,7 +37,7 @@
android:layout_marginBottom="@dimen/chooser_view_spacing"
android:id="@androidprv:id/content_preview_file_layout">
- <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView"
+ <com.android.intentresolver.widget.RoundedRectImageView
android:id="@androidprv:id/content_preview_file_thumbnail"
android:layout_width="75dp"
android:layout_height="75dp"
@@ -68,13 +68,10 @@
android:singleLine="true"/>
</LinearLayout>
- <include
- android:id="@androidprv:id/chooser_action_row"
- layout="@layout/chooser_action_row"
- android:layout_width="@dimen/chooser_preview_width"
- android:layout_height="wrap_content"
- android:layout_marginBottom="@dimen/chooser_view_spacing"
- android:layout_gravity="center"
- />
+ <ViewStub
+ android:id="@+id/action_row_stub"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
</LinearLayout>
diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml
index 96054eb..5c32414 100644
--- a/java/res/layout/chooser_grid_preview_image.xml
+++ b/java/res/layout/chooser_grid_preview_image.xml
@@ -24,70 +24,19 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?android:attr/colorBackground">
- <RelativeLayout
+
+ <com.android.intentresolver.widget.ImagePreviewView
android:id="@androidprv:id/content_preview_image_area"
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:background="?android:attr/colorBackground" />
- <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView"
- android:id="@androidprv:id/content_preview_image_1_large"
- android:layout_width="120dp"
- android:layout_height="104dp"
- android:layout_alignParentTop="true"
- android:adjustViewBounds="true"
- android:gravity="center"
- android:scaleType="centerCrop"/>
-
- <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView"
- android:id="@androidprv:id/content_preview_image_2_large"
- android:visibility="gone"
- android:layout_width="120dp"
- android:layout_height="104dp"
- android:layout_alignParentTop="true"
- android:layout_toRightOf="@androidprv:id/content_preview_image_1_large"
- android:layout_marginLeft="10dp"
- android:adjustViewBounds="true"
- android:gravity="center"
- android:scaleType="centerCrop"/>
-
- <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView"
- android:id="@androidprv:id/content_preview_image_2_small"
- android:visibility="gone"
- android:layout_width="120dp"
- android:layout_height="65dp"
- android:layout_alignParentTop="true"
- android:layout_toRightOf="@androidprv:id/content_preview_image_1_large"
- android:layout_marginLeft="10dp"
- android:adjustViewBounds="true"
- android:gravity="center"
- android:scaleType="centerCrop"/>
-
- <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView"
- android:id="@androidprv:id/content_preview_image_3_small"
- android:visibility="gone"
- android:layout_width="120dp"
- android:layout_height="65dp"
- android:layout_below="@androidprv:id/content_preview_image_2_small"
- android:layout_toRightOf="@androidprv:id/content_preview_image_1_large"
- android:layout_marginLeft="10dp"
- android:layout_marginTop="10dp"
- android:adjustViewBounds="true"
- android:gravity="center"
- android:scaleType="centerCrop"/>
-
- </RelativeLayout>
-
- <include
- android:id="@androidprv:id/chooser_action_row"
- layout="@layout/chooser_action_row"
- android:layout_width="@dimen/chooser_preview_width"
- android:layout_height="wrap_content"
- android:layout_marginBottom="@dimen/chooser_view_spacing"
- android:layout_gravity="center"
- />
+ <ViewStub
+ android:id="@+id/action_row_stub"
+ android:layout_width="match_parent"
+ 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 a9ed71b..db7282e 100644
--- a/java/res/layout/chooser_grid_preview_text.xml
+++ b/java/res/layout/chooser_grid_preview_text.xml
@@ -52,14 +52,10 @@
</RelativeLayout>
- <include
- android:id="@androidprv:id/chooser_action_row"
- layout="@layout/chooser_action_row"
- android:layout_width="@dimen/chooser_preview_width"
- android:layout_height="wrap_content"
- android:layout_marginBottom="@dimen/chooser_view_spacing"
- android:layout_gravity="center"
- />
+ <ViewStub
+ android:id="@+id/action_row_stub"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
<!-- Required sub-layout so we can get the nice rounded corners-->
<!-- around this section -->
@@ -75,7 +71,7 @@
android:background="@androidprv:drawable/chooser_content_preview_rounded"
android:id="@androidprv:id/content_preview_title_layout">
- <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView"
+ <com.android.intentresolver.widget.RoundedRectImageView
android:id="@androidprv:id/content_preview_thumbnail"
android:layout_width="75dp"
android:layout_height="75dp"
diff --git a/java/res/layout/chooser_list_per_profile.xml b/java/res/layout/chooser_list_per_profile.xml
index 8d876cd..1753e2f 100644
--- a/java/res/layout/chooser_list_per_profile.xml
+++ b/java/res/layout/chooser_list_per_profile.xml
@@ -16,12 +16,13 @@
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
- <com.android.internal.widget.RecyclerView
+ <androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
- androidprv:layoutManager="com.android.intentresolver.ChooserGridLayoutManager"
+ app:layoutManager="com.android.intentresolver.ChooserGridLayoutManager"
android:id="@androidprv:id/resolver_list"
android:clipToPadding="false"
android:background="?android:attr/colorBackground"
diff --git a/java/res/layout/image_preview_view.xml b/java/res/layout/image_preview_view.xml
new file mode 100644
index 0000000..d2f9469
--- /dev/null
+++ b/java/res/layout/image_preview_view.xml
@@ -0,0 +1,72 @@
+<!--
+ ~ 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.
+ -->
+
+<merge
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:paddingBottom="@dimen/chooser_view_spacing"
+ android:background="?android:attr/colorBackground">
+
+ <com.android.intentresolver.widget.RoundedRectImageView
+ android:id="@androidprv:id/content_preview_image_1_large"
+ android:layout_width="120dp"
+ android:layout_height="104dp"
+ android:layout_alignParentTop="true"
+ android:adjustViewBounds="true"
+ android:gravity="center"
+ android:scaleType="centerCrop"/>
+
+ <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_alignParentTop="true"
+ android:layout_toRightOf="@androidprv:id/content_preview_image_1_large"
+ android:layout_marginLeft="10dp"
+ android:adjustViewBounds="true"
+ android:gravity="center"
+ android:scaleType="centerCrop"/>
+
+ <com.android.intentresolver.widget.RoundedRectImageView
+ android:id="@androidprv:id/content_preview_image_2_small"
+ android:visibility="gone"
+ android:layout_width="120dp"
+ android:layout_height="65dp"
+ android:layout_alignParentTop="true"
+ android:layout_toRightOf="@androidprv:id/content_preview_image_1_large"
+ android:layout_marginLeft="10dp"
+ android:adjustViewBounds="true"
+ android:gravity="center"
+ android:scaleType="centerCrop"/>
+
+ <com.android.intentresolver.widget.RoundedRectImageView
+ android:id="@androidprv:id/content_preview_image_3_small"
+ android:visibility="gone"
+ android:layout_width="120dp"
+ android:layout_height="65dp"
+ android:layout_below="@androidprv:id/content_preview_image_2_small"
+ android:layout_toRightOf="@androidprv:id/content_preview_image_1_large"
+ android:layout_marginLeft="10dp"
+ android:layout_marginTop="10dp"
+ android:adjustViewBounds="true"
+ android:gravity="center"
+ android:scaleType="centerCrop"/>
+
+</merge>
diff --git a/java/res/layout/miniresolver.xml b/java/res/layout/miniresolver.xml
index ab65aa9..7e31de5 100644
--- a/java/res/layout/miniresolver.xml
+++ b/java/res/layout/miniresolver.xml
@@ -14,20 +14,21 @@
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
-<com.android.internal.widget.ResolverDrawerLayout
+<com.android.intentresolver.widget.ResolverDrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:maxWidth="@dimen/resolver_max_width"
- androidprv:maxCollapsedHeight="@dimen/resolver_max_collapsed_height"
- androidprv:maxCollapsedHeightSmall="56dp"
+ app:maxCollapsedHeight="@dimen/resolver_max_collapsed_height"
+ app:maxCollapsedHeightSmall="56dp"
android:id="@androidprv:id/contentPanel">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:elevation="@dimen/resolver_elevation"
android:paddingTop="24dp"
android:paddingStart="@dimen/resolver_edge_margin"
@@ -62,18 +63,18 @@
android:id="@androidprv:id/button_bar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:paddingTop="32dp"
android:paddingBottom="@dimen/resolver_button_bar_spacing"
android:orientation="vertical"
android:background="?android:attr/colorBackground"
- androidprv:layout_ignoreOffset="true">
+ app:layout_ignoreOffset="true">
<RelativeLayout
style="?android:attr/buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_ignoreOffset="true"
- androidprv:layout_hasNestedScrollIndicator="true"
+ app:layout_ignoreOffset="true"
+ app:layout_hasNestedScrollIndicator="true"
android:gravity="end|center_vertical"
android:orientation="horizontal"
android:layoutDirection="locale"
@@ -112,4 +113,4 @@
/>
</RelativeLayout>
</LinearLayout>
-</com.android.internal.widget.ResolverDrawerLayout>
+</com.android.intentresolver.widget.ResolverDrawerLayout>
diff --git a/java/res/layout/resolver_different_item_header.xml b/java/res/layout/resolver_different_item_header.xml
index 4f80159..79ce682 100644
--- a/java/res/layout/resolver_different_item_header.xml
+++ b/java/res/layout/resolver_different_item_header.xml
@@ -19,9 +19,10 @@
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:text="@string/use_a_different_app"
android:textColor="?android:attr/textColorPrimary"
android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium"
diff --git a/java/res/layout/resolver_list.xml b/java/res/layout/resolver_list.xml
index 179c407..44b14ba 100644
--- a/java/res/layout/resolver_list.xml
+++ b/java/res/layout/resolver_list.xml
@@ -16,21 +16,22 @@
* limitations under the License.
*/
-->
-<com.android.internal.widget.ResolverDrawerLayout
+<com.android.intentresolver.widget.ResolverDrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:maxWidth="@dimen/resolver_max_width"
- androidprv:maxCollapsedHeight="@dimen/resolver_max_collapsed_height"
- androidprv:maxCollapsedHeightSmall="56dp"
+ app:maxCollapsedHeight="@dimen/resolver_max_collapsed_height"
+ app:maxCollapsedHeightSmall="56dp"
android:id="@androidprv:id/contentPanel">
<RelativeLayout
android:id="@androidprv:id/title_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:elevation="@dimen/resolver_elevation"
android:paddingTop="@dimen/resolver_small_margin"
android:paddingStart="@dimen/resolver_edge_margin"
@@ -66,7 +67,7 @@
<View
android:id="@androidprv:id/divider"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/colorBackground"
@@ -114,10 +115,10 @@
android:id="@androidprv:id/button_bar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:orientation="vertical"
android:background="?android:attr/colorBackground"
- androidprv:layout_ignoreOffset="true">
+ app:layout_ignoreOffset="true">
<View
android:id="@androidprv:id/resolver_button_bar_divider"
android:layout_width="match_parent"
@@ -130,8 +131,8 @@
style="?android:attr/buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_ignoreOffset="true"
- androidprv:layout_hasNestedScrollIndicator="true"
+ app:layout_ignoreOffset="true"
+ app:layout_hasNestedScrollIndicator="true"
android:gravity="end|center_vertical"
android:orientation="horizontal"
android:layoutDirection="locale"
@@ -169,4 +170,4 @@
android:onClick="onButtonClick" />
</LinearLayout>
</LinearLayout>
-</com.android.internal.widget.ResolverDrawerLayout>
+</com.android.intentresolver.widget.ResolverDrawerLayout>
diff --git a/java/res/layout/resolver_list_with_default.xml b/java/res/layout/resolver_list_with_default.xml
index 341c58e..192a598 100644
--- a/java/res/layout/resolver_list_with_default.xml
+++ b/java/res/layout/resolver_list_with_default.xml
@@ -16,19 +16,20 @@
* limitations under the License.
*/
-->
-<com.android.internal.widget.ResolverDrawerLayout
+<com.android.intentresolver.widget.ResolverDrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:maxWidth="@dimen/resolver_max_width"
- androidprv:maxCollapsedHeight="@dimen/resolver_max_collapsed_height_with_default"
+ app:maxCollapsedHeight="@dimen/resolver_max_collapsed_height_with_default"
android:id="@androidprv:id/contentPanel">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:orientation="vertical"
android:background="@drawable/bottomsheet_background"
android:paddingTop="@dimen/resolver_small_margin"
@@ -105,7 +106,7 @@
style="?android:attr/buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:gravity="end|center_vertical"
android:orientation="horizontal"
android:layoutDirection="locale"
@@ -146,7 +147,7 @@
<View
android:id="@androidprv:id/divider"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/colorBackground"
@@ -154,14 +155,14 @@
<FrameLayout
android:id="@androidprv:id/stub"
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/colorBackground"/>
<TabHost
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:id="@androidprv:id/profile_tabhost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -198,9 +199,9 @@
</TabHost>
<View
- androidprv:layout_alwaysShow="true"
+ app:layout_alwaysShow="true"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/colorBackground"
android:foreground="?android:attr/dividerVertical" />
-</com.android.internal.widget.ResolverDrawerLayout>
+</com.android.intentresolver.widget.ResolverDrawerLayout>
diff --git a/java/res/layout/scrollable_chooser_action_row.xml b/java/res/layout/scrollable_chooser_action_row.xml
new file mode 100644
index 0000000..cb5dabf
--- /dev/null
+++ b/java/res/layout/scrollable_chooser_action_row.xml
@@ -0,0 +1,30 @@
+<!--
+ ~ Copyright (C) 2019 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center_horizontal">
+
+ <com.android.intentresolver.widget.ScrollableActionRow
+ android:id="@androidprv:id/chooser_action_row"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:gravity="center" />
+</FrameLayout>
diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml
index 3ec7c2f..2f2bbda 100644
--- a/java/res/values/attrs.xml
+++ b/java/res/values/attrs.xml
@@ -26,6 +26,12 @@
<attr name="maxCollapsedHeightSmall" format="dimension" />
<!-- Whether the Drawer should be positioned at the top rather than at the bottom. -->
<attr name="showAtTop" format="boolean" />
+ <!-- By default `ResolverDrawerLayout`’s children views with `layout_ignoreOffset` property
+ set to true have a fixed position in the layout that won’t be affected by the drawer’s
+ movements. This property alternates that behavior. It specifies a child view’s id that
+ will push all ignoreOffset siblings below it when the drawer is moved i.e. setting the
+ top limit the ignoreOffset elements. -->
+ <attr name="ignoreOffsetTopLimit" format="reference" />
</declare-styleable>
<declare-styleable name="ResolverDrawerLayout_LayoutParams">
diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml
index 2d6fe81..93cb463 100644
--- a/java/res/values/dimens.xml
+++ b/java/res/values/dimens.xml
@@ -50,4 +50,6 @@
<dimen name="resolver_profile_tab_margin">4dp</dimen>
<dimen name="chooser_action_button_icon_size">18dp</dimen>
+ <dimen name="chooser_action_view_icon_size">22dp</dimen>
+ <dimen name="chooser_action_margin">0dp</dimen>
</resources>
diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
index 4f6c0bf..17dbb8f 100644
--- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java
@@ -16,27 +16,25 @@
package com.android.intentresolver;
import android.annotation.IntDef;
+import android.annotation.NonNull;
import android.annotation.Nullable;
+import android.annotation.UserIdInt;
import android.app.AppGlobals;
-import android.app.admin.DevicePolicyEventLogger;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.IPackageManager;
-import android.content.pm.ResolveInfo;
-import android.os.AsyncTask;
import android.os.Trace;
import android.os.UserHandle;
-import android.os.UserManager;
-import android.stats.devicepolicy.DevicePolicyEnums;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
+import androidx.viewpager.widget.PagerAdapter;
+import androidx.viewpager.widget.ViewPager;
+
import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.widget.PagerAdapter;
-import com.android.internal.widget.ViewPager;
import java.util.HashSet;
import java.util.List;
@@ -59,73 +57,32 @@
private final Context mContext;
private int mCurrentPage;
private OnProfileSelectedListener mOnProfileSelectedListener;
- private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
+
private Set<Integer> mLoadedPages;
- private final UserHandle mPersonalProfileUserHandle;
+ private final EmptyStateProvider mEmptyStateProvider;
private final UserHandle mWorkProfileUserHandle;
- private Injector mInjector;
- private boolean mIsWaitingToEnableWorkProfile;
+ private final QuietModeManager mQuietModeManager;
AbstractMultiProfilePagerAdapter(Context context, int currentPage,
- UserHandle personalProfileUserHandle,
+ EmptyStateProvider emptyStateProvider,
+ QuietModeManager quietModeManager,
UserHandle workProfileUserHandle) {
mContext = Objects.requireNonNull(context);
mCurrentPage = currentPage;
mLoadedPages = new HashSet<>();
- mPersonalProfileUserHandle = personalProfileUserHandle;
mWorkProfileUserHandle = workProfileUserHandle;
- UserManager userManager = context.getSystemService(UserManager.class);
- mInjector = new Injector() {
- @Override
- public boolean hasCrossProfileIntents(List<Intent> intents, int sourceUserId,
- int targetUserId) {
- return AbstractMultiProfilePagerAdapter.this
- .hasCrossProfileIntents(intents, sourceUserId, targetUserId);
- }
-
- @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;
- }
- };
+ mEmptyStateProvider = emptyStateProvider;
+ mQuietModeManager = quietModeManager;
}
- protected void markWorkProfileEnabledBroadcastReceived() {
- mIsWaitingToEnableWorkProfile = false;
- }
-
- protected boolean isWaitingToEnableWorkProfile() {
- return mIsWaitingToEnableWorkProfile;
- }
-
- /**
- * Overrides the default {@link Injector} for testing purposes.
- */
- @VisibleForTesting
- public void setInjector(Injector injector) {
- mInjector = injector;
- }
-
- protected boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
- return mInjector.isQuietModeEnabled(workProfileUserHandle);
+ private boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
+ return mQuietModeManager.isQuietModeEnabled(workProfileUserHandle);
}
void setOnProfileSelectedListener(OnProfileSelectedListener listener) {
mOnProfileSelectedListener = listener;
}
- void setOnSwitchOnWorkSelectedListener(OnSwitchOnWorkSelectedListener listener) {
- mOnSwitchOnWorkSelectedListener = listener;
- }
-
Context getContext() {
return mContext;
}
@@ -191,7 +148,7 @@
@VisibleForTesting
public UserHandle getCurrentUserHandle() {
- return getActiveListAdapter().mResolverListController.getUserHandle();
+ return getActiveListAdapter().getUserHandle();
}
@Override
@@ -216,6 +173,10 @@
*/
abstract ProfileDescriptor getItem(int pageIndex);
+ protected ViewGroup getEmptyStateView(int pageIndex) {
+ return getItem(pageIndex).getEmptyStateView();
+ }
+
/**
* Returns the number of {@link ProfileDescriptor} objects.
* <p>For a normal consumer device with only one user returns <code>1</code>.
@@ -279,8 +240,6 @@
abstract @Nullable ViewGroup getInactiveAdapterView();
- abstract String getMetricsCategory();
-
/**
* Rebuilds the tab that is currently visible to the user.
* <p>Returns {@code true} if rebuild has completed.
@@ -308,7 +267,7 @@
}
private int userHandleToPageIndex(UserHandle userHandle) {
- if (userHandle.equals(getPersonalListAdapter().mResolverListController.getUserHandle())) {
+ if (userHandle.equals(getPersonalListAdapter().getUserHandle())) {
return PROFILE_PERSONAL;
} else {
return PROFILE_WORK;
@@ -316,41 +275,18 @@
}
private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) {
- if (shouldShowNoCrossProfileIntentsEmptyState(activeListAdapter)) {
+ if (shouldSkipRebuild(activeListAdapter)) {
activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true);
return false;
}
return activeListAdapter.rebuildList(doPostProcessing);
}
- private boolean shouldShowNoCrossProfileIntentsEmptyState(
- ResolverListAdapter activeListAdapter) {
- UserHandle listUserHandle = activeListAdapter.getUserHandle();
- return UserHandle.myUserId() != listUserHandle.getIdentifier()
- && allowShowNoCrossProfileIntentsEmptyState()
- && !mInjector.hasCrossProfileIntents(activeListAdapter.getIntents(),
- UserHandle.myUserId(), listUserHandle.getIdentifier());
+ private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) {
+ EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter);
+ return emptyState != null && emptyState.shouldSkipDataRebuild();
}
- boolean allowShowNoCrossProfileIntentsEmptyState() {
- return true;
- }
-
- protected abstract void showWorkProfileOffEmptyState(
- ResolverListAdapter activeListAdapter, View.OnClickListener listener);
-
- protected abstract void showNoPersonalToWorkIntentsEmptyState(
- ResolverListAdapter activeListAdapter);
-
- protected abstract void showNoPersonalAppsAvailableEmptyState(
- ResolverListAdapter activeListAdapter);
-
- protected abstract void showNoWorkAppsAvailableEmptyState(
- ResolverListAdapter activeListAdapter);
-
- protected abstract void showNoWorkToPersonalIntentsEmptyState(
- ResolverListAdapter activeListAdapter);
-
/**
* The empty state screens are shown according to their priority:
* <ol>
@@ -365,103 +301,88 @@
* anyway.
*/
void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) {
- if (maybeShowNoCrossProfileIntentsEmptyState(listAdapter)) {
- return;
- }
- if (maybeShowWorkProfileOffEmptyState(listAdapter)) {
- return;
- }
- maybeShowNoAppsAvailableEmptyState(listAdapter);
- }
+ final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter);
- private boolean maybeShowNoCrossProfileIntentsEmptyState(ResolverListAdapter listAdapter) {
- if (!shouldShowNoCrossProfileIntentsEmptyState(listAdapter)) {
- return false;
+ if (emptyState == null) {
+ return;
}
- if (listAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) {
- DevicePolicyEventLogger.createEvent(
- DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL)
- .setStrings(getMetricsCategory())
- .write();
- showNoWorkToPersonalIntentsEmptyState(listAdapter);
- } else {
- DevicePolicyEventLogger.createEvent(
- DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK)
- .setStrings(getMetricsCategory())
- .write();
- showNoPersonalToWorkIntentsEmptyState(listAdapter);
+
+ emptyState.onEmptyStateShown();
+
+ View.OnClickListener clickListener = null;
+
+ if (emptyState.getButtonClickListener() != null) {
+ clickListener = v -> emptyState.getButtonClickListener().onClick(() -> {
+ ProfileDescriptor descriptor = getItem(
+ userHandleToPageIndex(listAdapter.getUserHandle()));
+ AbstractMultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView());
+ });
}
- return true;
+
+ showEmptyState(listAdapter, emptyState, clickListener);
}
/**
- * Returns {@code true} if the work profile off empty state screen is shown.
+ * Class to get user id of the current process
*/
- private boolean maybeShowWorkProfileOffEmptyState(ResolverListAdapter listAdapter) {
- UserHandle listUserHandle = listAdapter.getUserHandle();
- if (!listUserHandle.equals(mWorkProfileUserHandle)
- || !mInjector.isQuietModeEnabled(mWorkProfileUserHandle)
- || listAdapter.getCount() == 0) {
- return false;
- }
- DevicePolicyEventLogger
- .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED)
- .setStrings(getMetricsCategory())
- .write();
- showWorkProfileOffEmptyState(listAdapter,
- v -> {
- ProfileDescriptor descriptor = getItem(
- userHandleToPageIndex(listAdapter.getUserHandle()));
- showSpinner(descriptor.getEmptyStateView());
- if (mOnSwitchOnWorkSelectedListener != null) {
- mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
- }
- mInjector.requestQuietModeEnabled(false, mWorkProfileUserHandle);
- });
- return true;
- }
-
- private void maybeShowNoAppsAvailableEmptyState(ResolverListAdapter listAdapter) {
- UserHandle listUserHandle = listAdapter.getUserHandle();
- if (mWorkProfileUserHandle != null
- && (UserHandle.myUserId() == listUserHandle.getIdentifier()
- || !hasAppsInOtherProfile(listAdapter))) {
- DevicePolicyEventLogger.createEvent(
- DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED)
- .setStrings(getMetricsCategory())
- .setBoolean(/*isPersonalProfile*/ listUserHandle == mPersonalProfileUserHandle)
- .write();
- if (listUserHandle == mPersonalProfileUserHandle) {
- showNoPersonalAppsAvailableEmptyState(listAdapter);
- } else {
- showNoWorkAppsAvailableEmptyState(listAdapter);
- }
- } else if (mWorkProfileUserHandle == null) {
- showConsumerUserNoAppsAvailableEmptyState(listAdapter);
+ public static class MyUserIdProvider {
+ /**
+ * @return user id of the current process
+ */
+ public int getMyUserId() {
+ return UserHandle.myUserId();
}
}
- protected void showEmptyState(ResolverListAdapter activeListAdapter, String title,
- String subtitle) {
- showEmptyState(activeListAdapter, title, subtitle, /* buttonOnClick */ null);
+ /**
+ * Utility class to check if there are cross profile intents, it is in a separate class so
+ * it could be mocked in tests
+ */
+ public static class CrossProfileIntentsChecker {
+
+ private final ContentResolver mContentResolver;
+
+ public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) {
+ mContentResolver = contentResolver;
+ }
+
+ /**
+ * Returns {@code true} if at least one of the provided {@code intents} can be forwarded
+ * from {@code source} (user id) to {@code target} (user id).
+ */
+ public boolean hasCrossProfileIntents(List<Intent> intents, @UserIdInt int source,
+ @UserIdInt int target) {
+ IPackageManager packageManager = AppGlobals.getPackageManager();
+
+ return intents.stream().anyMatch(intent ->
+ null != IntentForwarderActivity.canForward(intent, source, target,
+ packageManager, mContentResolver));
+ }
}
- protected void showEmptyState(ResolverListAdapter activeListAdapter,
- String title, String subtitle, View.OnClickListener buttonOnClick) {
+ protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState,
+ View.OnClickListener buttonOnClick) {
ProfileDescriptor descriptor = getItem(
userHandleToPageIndex(activeListAdapter.getUserHandle()));
descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE);
ViewGroup emptyStateView = descriptor.getEmptyStateView();
- resetViewVisibilitiesForWorkProfileEmptyState(emptyStateView);
+ resetViewVisibilitiesForEmptyState(emptyStateView);
emptyStateView.setVisibility(View.VISIBLE);
View container = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_container);
setupContainerPadding(container);
TextView titleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title);
- titleView.setText(title);
+ String title = emptyState.getTitle();
+ if (title != null) {
+ titleView.setVisibility(View.VISIBLE);
+ titleView.setText(title);
+ } else {
+ titleView.setVisibility(View.GONE);
+ }
TextView subtitleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle);
+ String subtitle = emptyState.getSubtitle();
if (subtitle != null) {
subtitleView.setVisibility(View.VISIBLE);
subtitleView.setText(subtitle);
@@ -469,6 +390,9 @@
subtitleView.setVisibility(View.GONE);
}
+ View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty);
+ defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE);
+
Button button = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button);
button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE);
button.setOnClickListener(buttonOnClick);
@@ -482,22 +406,6 @@
*/
protected void setupContainerPadding(View container) {}
- private void showConsumerUserNoAppsAvailableEmptyState(ResolverListAdapter activeListAdapter) {
- ProfileDescriptor descriptor = getItem(
- userHandleToPageIndex(activeListAdapter.getUserHandle()));
- descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE);
- View emptyStateView = descriptor.getEmptyStateView();
- resetViewVisibilitiesForConsumerUserEmptyState(emptyStateView);
- emptyStateView.setVisibility(View.VISIBLE);
-
- activeListAdapter.markTabLoaded();
- }
-
- private boolean isSpinnerShowing(View emptyStateView) {
- return emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).getVisibility()
- == View.VISIBLE;
- }
-
private void showSpinner(View emptyStateView) {
emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE);
emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE);
@@ -505,7 +413,7 @@
emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE);
}
- private void resetViewVisibilitiesForWorkProfileEmptyState(View emptyStateView) {
+ private void resetViewVisibilitiesForEmptyState(View emptyStateView) {
emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.VISIBLE);
emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE);
emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE);
@@ -513,14 +421,6 @@
emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE);
}
- private void resetViewVisibilitiesForConsumerUserEmptyState(View emptyStateView) {
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.GONE);
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.GONE);
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.GONE);
- emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.GONE);
- emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.VISIBLE);
- }
-
protected void showListView(ResolverListAdapter activeListAdapter) {
ProfileDescriptor descriptor = getItem(
userHandleToPageIndex(activeListAdapter.getUserHandle()));
@@ -529,33 +429,6 @@
emptyStateView.setVisibility(View.GONE);
}
- private boolean hasCrossProfileIntents(List<Intent> intents, int source, int target) {
- IPackageManager packageManager = AppGlobals.getPackageManager();
- ContentResolver contentResolver = mContext.getContentResolver();
- for (Intent intent : intents) {
- if (IntentForwarderActivity.canForward(intent, source, target, packageManager,
- contentResolver) != null) {
- return true;
- }
- }
- return false;
- }
-
- private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) {
- if (mWorkProfileUserHandle == null) {
- return false;
- }
- List<ResolverActivity.ResolvedComponentInfo> resolversForIntent =
- adapter.getResolversForUser(UserHandle.of(UserHandle.myUserId()));
- for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) {
- ResolveInfo resolveInfo = info.getResolveInfoAt(0);
- if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) {
- return true;
- }
- }
- return false;
- }
-
boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) {
int count = listAdapter.getUnfilteredCount();
return (count == 0 && listAdapter.getPlaceholderCount() == 0)
@@ -563,7 +436,7 @@
&& isQuietModeEnabled(mWorkProfileUserHandle));
}
- protected class ProfileDescriptor {
+ protected static class ProfileDescriptor {
final ViewGroup rootView;
private final ViewGroup mEmptyStateView;
ProfileDescriptor(ViewGroup rootView) {
@@ -599,6 +472,99 @@
}
/**
+ * Returns an empty state to show for the current profile page (tab) if necessary.
+ * This could be used e.g. to show a blocker on a tab if device management policy doesn't
+ * allow to use it or there are no apps available.
+ */
+ public interface EmptyStateProvider {
+ /**
+ * When a non-null empty state is returned the corresponding profile page will show
+ * this empty state
+ * @param resolverListAdapter the current adapter
+ */
+ @Nullable
+ default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
+ return null;
+ }
+ }
+
+ /**
+ * Empty state provider that combines multiple providers. Providers earlier in the list have
+ * priority, that is if there is a provider that returns non-null empty state then all further
+ * providers will be ignored.
+ */
+ public static class CompositeEmptyStateProvider implements EmptyStateProvider {
+
+ private final EmptyStateProvider[] mProviders;
+
+ public CompositeEmptyStateProvider(EmptyStateProvider... providers) {
+ mProviders = providers;
+ }
+
+ @Nullable
+ @Override
+ public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
+ for (EmptyStateProvider provider : mProviders) {
+ EmptyState emptyState = provider.getEmptyState(resolverListAdapter);
+ if (emptyState != null) {
+ return emptyState;
+ }
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Describes how the blocked empty state should look like for a profile tab
+ */
+ public interface EmptyState {
+ /**
+ * Title that will be shown on the empty state
+ */
+ @Nullable
+ default String getTitle() { return null; }
+
+ /**
+ * Subtitle that will be shown underneath the title on the empty state
+ */
+ @Nullable
+ default String getSubtitle() { return null; }
+
+ /**
+ * If non-null then a button will be shown and this listener will be called
+ * when the button is clicked
+ */
+ @Nullable
+ default ClickListener getButtonClickListener() { return null; }
+
+ /**
+ * If true then default text ('No apps can perform this action') and style for the empty
+ * state will be applied, title and subtitle will be ignored.
+ */
+ default boolean useDefaultEmptyView() { return false; }
+
+ /**
+ * Returns true if for this empty state we should skip rebuilding of the apps list
+ * for this tab.
+ */
+ default boolean shouldSkipDataRebuild() { return false; }
+
+ /**
+ * Called when empty state is shown, could be used e.g. to track analytics events
+ */
+ default void onEmptyStateShown() {}
+
+ interface ClickListener {
+ void onClick(TabControl currentTab);
+ }
+
+ interface TabControl {
+ void showSpinner();
+ }
+ }
+
+
+ /**
* Listener for when the user switches on the work profile from the work tab.
*/
interface OnSwitchOnWorkSelectedListener {
@@ -611,14 +577,7 @@
/**
* Describes an injector to be used for cross profile functionality. Overridable for testing.
*/
- @VisibleForTesting
- public interface Injector {
- /**
- * Returns {@code true} if at least one of the provided {@code intents} can be forwarded
- * from {@code sourceUserId} to {@code targetUserId}.
- */
- boolean hasCrossProfileIntents(List<Intent> intents, int sourceUserId, int targetUserId);
-
+ public interface QuietModeManager {
/**
* Returns whether the given profile is in quiet mode or not.
*/
@@ -628,5 +587,15 @@
* 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/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java
index 14d7742..ceab62b 100644
--- a/java/src/com/android/intentresolver/ChooserActivity.java
+++ b/java/src/com/android/intentresolver/ChooserActivity.java
@@ -16,29 +16,26 @@
package com.android.intentresolver;
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL;
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK;
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL;
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK;
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE;
+import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL;
+import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK;
+
import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET;
-import static java.lang.annotation.RetentionPolicy.SOURCE;
-
-import android.animation.Animator;
-import android.animation.AnimatorListenerAdapter;
-import android.animation.AnimatorSet;
-import android.animation.ObjectAnimator;
-import android.animation.ValueAnimator;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.ActivityOptions;
-import android.app.SharedElementCallback;
-import android.app.prediction.AppPredictionContext;
-import android.app.prediction.AppPredictionManager;
import android.app.prediction.AppPredictor;
import android.app.prediction.AppTarget;
import android.app.prediction.AppTargetEvent;
import android.app.prediction.AppTargetId;
-import android.compat.annotation.UnsupportedAppUsage;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ComponentName;
@@ -50,96 +47,75 @@
import android.content.IntentSender.SendIntentException;
import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
-import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.ResolveInfo;
import android.content.pm.ShortcutInfo;
-import android.content.pm.ShortcutManager;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.database.Cursor;
-import android.database.DataSetObserver;
import android.graphics.Bitmap;
-import android.graphics.Canvas;
-import android.graphics.Color;
import android.graphics.Insets;
-import android.graphics.Paint;
-import android.graphics.Path;
-import android.graphics.drawable.AnimatedVectorDrawable;
import android.graphics.drawable.Drawable;
-import android.metrics.LogMaker;
import android.net.Uri;
-import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
-import android.os.Message;
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.DocumentsContract;
-import android.provider.Downloads;
-import android.provider.OpenableColumns;
import android.provider.Settings;
import android.service.chooser.ChooserTarget;
import android.text.TextUtils;
-import android.util.AttributeSet;
-import android.util.HashedStringCache;
import android.util.Log;
-import android.util.PluralsMessageFormatter;
import android.util.Size;
import android.util.Slog;
-import android.view.LayoutInflater;
+import android.util.SparseArray;
import android.view.View;
-import android.view.View.MeasureSpec;
-import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewTreeObserver;
import android.view.WindowInsets;
-import android.view.animation.AccelerateInterpolator;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
-import android.view.animation.DecelerateInterpolator;
import android.view.animation.LinearInterpolator;
-import android.widget.Button;
-import android.widget.ImageView;
-import android.widget.Space;
import android.widget.TextView;
-import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter;
-import com.android.intentresolver.ResolverListAdapter.ViewHolder;
-import com.android.intentresolver.chooser.ChooserTargetInfo;
+import androidx.annotation.MainThread;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewpager.widget.ViewPager;
+
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
+import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
-import com.android.intentresolver.chooser.NotSelectableTargetInfo;
-import com.android.intentresolver.chooser.SelectableTargetInfo;
-import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator;
import com.android.intentresolver.chooser.TargetInfo;
-
+import com.android.intentresolver.grid.ChooserGridAdapter;
+import com.android.intentresolver.grid.DirectShareViewHolder;
+import com.android.intentresolver.model.AbstractResolverComparator;
+import com.android.intentresolver.model.AppPredictionServiceResolverComparator;
+import com.android.intentresolver.model.ResolverRankerServiceResolverComparator;
+import com.android.intentresolver.shortcuts.AppPredictorFactory;
+import com.android.intentresolver.shortcuts.ShortcutLoader;
+import com.android.intentresolver.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.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.android.internal.util.FrameworkStatsLog;
-import com.android.internal.widget.GridLayoutManager;
-import com.android.internal.widget.RecyclerView;
-import com.android.internal.widget.ResolverDrawerLayout;
-import com.android.internal.widget.ViewPager;
-
-import com.google.android.collect.Lists;
import java.io.File;
import java.io.IOException;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
-import java.net.URISyntaxException;
import java.text.Collator;
import java.util.ArrayList;
import java.util.Collections;
@@ -148,25 +124,19 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
-import java.util.function.Supplier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.Consumer;
/**
* The Chooser Activity handles intent resolution specifically for sharing intents -
- * for example, those generated by @see android.content.Intent#createChooser(Intent, CharSequence).
+ * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}.
*
*/
public class ChooserActivity extends ResolverActivity implements
- ChooserListAdapter.ChooserListCommunicator,
- SelectableTargetInfoCommunicator {
+ ResolverListAdapter.ResolverListCommunicator {
private static final String TAG = "ChooserActivity";
- private AppPredictor mPersonalAppPredictor;
- private AppPredictor mWorkAppPredictor;
- private boolean mShouldDisplayLandscape;
-
- @UnsupportedAppUsage
- public ChooserActivity() {
- }
/**
* Boolean extra to change the following behavior: Normally, ChooserActivity finishes itself
* in onStop when launched in a new task. If this extra is set to true, we do not finish
@@ -175,7 +145,6 @@
public static final String EXTRA_PRIVATE_RETAIN_IN_ON_STOP
= "com.android.internal.app.ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP";
-
/**
* Transition name for the first image preview.
* To be used for shared element transition into this activity.
@@ -190,44 +159,31 @@
private static final boolean DEBUG = true;
- private static final boolean USE_PREDICTION_MANAGER_FOR_SHARE_ACTIVITIES = true;
- // TODO(b/123088566) Share these in a better way.
- private static final String APP_PREDICTION_SHARE_UI_SURFACE = "share";
public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share";
- public static final String CHOOSER_TARGET = "chooser_target";
private static final String SHORTCUT_TARGET = "shortcut_target";
- private static final int APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT = 20;
- public static final String APP_PREDICTION_INTENT_FILTER_KEY = "intent_filter";
- private static final String SHARED_TEXT_KEY = "shared_text";
private static final String PLURALS_COUNT = "count";
private static final String PLURALS_FILE_NAME = "file_name";
private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image";
- private boolean mIsAppPredictorComponentAvailable;
- private Map<ChooserTarget, AppTarget> mDirectShareAppTargetCache;
- private Map<ChooserTarget, ShortcutInfo> mDirectShareShortcutInfoCache;
+ // TODO: these data structures are for one-time use in shuttling data from where they're
+ // populated in `ShortcutToChooserTargetConverter` to where they're consumed in
+ // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`.
+ // That flow should be refactored so that `ChooserActivity` isn't responsible for holding their
+ // intermediate data, and then these members can be removed.
+ private final Map<ChooserTarget, AppTarget> mDirectShareAppTargetCache = new HashMap<>();
+ private final Map<ChooserTarget, ShortcutInfo> mDirectShareShortcutInfoCache = new HashMap<>();
public static final int TARGET_TYPE_DEFAULT = 0;
public static final int TARGET_TYPE_CHOOSER_TARGET = 1;
public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2;
public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3;
- public static final int SELECTION_TYPE_SERVICE = 1;
- public static final int SELECTION_TYPE_APP = 2;
- public static final int SELECTION_TYPE_STANDARD = 3;
- public static final int SELECTION_TYPE_COPY = 4;
- public static final int SELECTION_TYPE_NEARBY = 5;
- public static final int SELECTION_TYPE_EDIT = 6;
-
private static final int SCROLL_STATUS_IDLE = 0;
private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1;
private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2;
- // statsd logger wrapper
- protected ChooserActivityLogger mChooserActivityLogger;
-
@IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = {
TARGET_TYPE_DEFAULT,
TARGET_TYPE_CHOOSER_TARGET,
@@ -237,294 +193,68 @@
@Retention(RetentionPolicy.SOURCE)
public @interface ShareTargetType {}
- /**
- * The transition time between placeholders for direct share to a message
- * indicating that non are available.
- */
- private static final int NO_DIRECT_SHARE_ANIM_IN_MILLIS = 200;
-
- private static final float DIRECT_SHARE_EXPANSION_RATE = 0.78f;
+ public static final float DIRECT_SHARE_EXPANSION_RATE = 0.78f;
private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7;
- private int mMaxHashSaltDays = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI,
+ private final int mMaxHashSaltDays = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI,
SystemUiDeviceConfigFlags.HASH_SALT_MAX_DAYS,
DEFAULT_SALT_EXPIRATION_DAYS);
- private static final boolean DEFAULT_IS_NEARBY_SHARE_FIRST_TARGET_IN_RANKED_APP = false;
- private boolean mIsNearbyShareFirstTargetInRankedApp =
- DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI,
- SystemUiDeviceConfigFlags.IS_NEARBY_SHARE_FIRST_TARGET_IN_RANKED_APP,
- DEFAULT_IS_NEARBY_SHARE_FIRST_TARGET_IN_RANKED_APP);
-
- private static final int DEFAULT_LIST_VIEW_UPDATE_DELAY_MS = 0;
-
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;
- @VisibleForTesting
- int mListViewUpdateDelayMs = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI,
- SystemUiDeviceConfigFlags.SHARESHEET_LIST_VIEW_UPDATE_DELAY,
- DEFAULT_LIST_VIEW_UPDATE_DELAY_MS);
+ /* 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
+ * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we
+ * should be able to make this assignment as "final."
+ */
+ @Nullable
+ private ChooserRequestParameters mChooserRequest;
- private Bundle mReplacementExtras;
- private IntentSender mChosenComponentSender;
- private IntentSender mRefinementIntentSender;
+ private boolean mShouldDisplayLandscape;
+ // statsd logger wrapper
+ protected ChooserActivityLogger mChooserActivityLogger;
+
+ @Nullable
private RefinementResultReceiver mRefinementResultReceiver;
- private ChooserTarget[] mCallerChooserTargets;
- private ComponentName[] mFilteredComponentNames;
-
- private Intent mReferrerFillInIntent;
private long mChooserShownTime;
protected boolean mIsSuccessfullySelected;
- private long mQueriedSharingShortcutsTimeMs;
-
private int mCurrAvailableWidth = 0;
private Insets mLastAppliedInsets = null;
private int mLastNumberOfChildren = -1;
private int mMaxTargetsPerRow = 1;
- private static final String TARGET_DETAILS_FRAGMENT_TAG = "targetDetailsFragment";
-
private static final int MAX_LOG_RANK_POSITION = 12;
+ // TODO: are these used anywhere? They should probably be migrated to ChooserRequestParameters.
private static final int MAX_EXTRA_INITIAL_INTENTS = 2;
private static final int MAX_EXTRA_CHOOSER_TARGETS = 2;
private SharedPreferences mPinnedSharedPrefs;
private static final String PINNED_SHARED_PREFS_NAME = "chooser_pin_settings";
- @Retention(SOURCE)
- @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT})
- private @interface ContentPreviewType {
- }
+ private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5);
- // Starting at 1 since 0 is considered "undefined" for some of the database transformations
- // of tron logs.
- protected static final int CONTENT_PREVIEW_IMAGE = 1;
- protected static final int CONTENT_PREVIEW_FILE = 2;
- protected static final int CONTENT_PREVIEW_TEXT = 3;
- protected MetricsLogger mMetricsLogger;
+ @Nullable
+ private ChooserContentPreviewCoordinator mPreviewCoordinator;
- private ContentPreviewCoordinator mPreviewCoord;
private int mScrollStatus = SCROLL_STATUS_IDLE;
@VisibleForTesting
protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter;
private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate =
- new EnterTransitionAnimationDelegate();
-
- private boolean mRemoveSharedElements = false;
+ new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout);
private View mContentView = null;
- private class ContentPreviewCoordinator {
- private static final int IMAGE_FADE_IN_MILLIS = 150;
- private static final int IMAGE_LOAD_TIMEOUT = 1;
- private static final int IMAGE_LOAD_INTO_VIEW = 2;
+ private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>();
- private final int mImageLoadTimeoutMillis =
- getResources().getInteger(R.integer.config_shortAnimTime);
-
- private final View mParentView;
- private boolean mHideParentOnFail;
- private boolean mAtLeastOneLoaded = false;
-
- class LoadUriTask {
- public final Uri mUri;
- public final int mImageResourceId;
- public final int mExtraCount;
- public final Bitmap mBmp;
-
- LoadUriTask(int imageResourceId, Uri uri, int extraCount, Bitmap bmp) {
- this.mImageResourceId = imageResourceId;
- this.mUri = uri;
- this.mExtraCount = extraCount;
- this.mBmp = bmp;
- }
- }
-
- // If at least one image loads within the timeout period, allow other
- // loads to continue. Otherwise terminate and optionally hide
- // the parent area
- private final Handler mHandler = new Handler() {
- @Override
- public void handleMessage(Message msg) {
- switch (msg.what) {
- case IMAGE_LOAD_TIMEOUT:
- maybeHideContentPreview();
- break;
-
- case IMAGE_LOAD_INTO_VIEW:
- if (isFinishing()) break;
-
- LoadUriTask task = (LoadUriTask) msg.obj;
- RoundedRectImageView imageView = mParentView.findViewById(
- task.mImageResourceId);
- if (task.mBmp == null) {
- imageView.setVisibility(View.GONE);
- maybeHideContentPreview();
- return;
- }
-
- mAtLeastOneLoaded = true;
- imageView.setVisibility(View.VISIBLE);
- imageView.setAlpha(0.0f);
- imageView.setImageBitmap(task.mBmp);
-
- ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f,
- 1.0f);
- fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
- fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS);
- fadeAnim.start();
-
- if (task.mExtraCount > 0) {
- imageView.setExtraImageCount(task.mExtraCount);
- }
-
- setupPreDrawForSharedElementTransition(imageView);
- }
- }
- };
-
- private void setupPreDrawForSharedElementTransition(View v) {
- v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
- @Override
- public boolean onPreDraw() {
- v.getViewTreeObserver().removeOnPreDrawListener(this);
-
- if (!mRemoveSharedElements && isActivityTransitionRunning()) {
- // Disable the window animations as it interferes with the
- // transition animation.
- getWindow().setWindowAnimations(0);
- }
- mEnterTransitionAnimationDelegate.markImagePreviewReady();
- return true;
- }
- });
- }
-
- ContentPreviewCoordinator(View parentView, boolean hideParentOnFail) {
- super();
-
- this.mParentView = parentView;
- this.mHideParentOnFail = hideParentOnFail;
- }
-
- private void loadUriIntoView(final int imageResourceId, final Uri uri,
- final int extraImages) {
- mHandler.sendEmptyMessageDelayed(IMAGE_LOAD_TIMEOUT, mImageLoadTimeoutMillis);
-
- AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
- int size = getResources().getDimensionPixelSize(
- R.dimen.chooser_preview_image_max_dimen);
- final Bitmap bmp = loadThumbnail(uri, new Size(size, size));
- final Message msg = Message.obtain();
- msg.what = IMAGE_LOAD_INTO_VIEW;
- msg.obj = new LoadUriTask(imageResourceId, uri, extraImages, bmp);
- mHandler.sendMessage(msg);
- });
- }
-
- private void cancelLoads() {
- mHandler.removeMessages(IMAGE_LOAD_INTO_VIEW);
- mHandler.removeMessages(IMAGE_LOAD_TIMEOUT);
- }
-
- private void maybeHideContentPreview() {
- if (!mAtLeastOneLoaded) {
- if (mHideParentOnFail) {
- Log.i(TAG, "Hiding image preview area. Timed out waiting for preview to load"
- + " within " + mImageLoadTimeoutMillis + "ms.");
- collapseParentView();
- if (shouldShowTabs()) {
- hideStickyContentPreview();
- } else if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) {
- mChooserMultiProfilePagerAdapter.getCurrentRootAdapter()
- .hideContentPreview();
- }
- mHideParentOnFail = false;
- }
- mRemoveSharedElements = true;
- mEnterTransitionAnimationDelegate.markImagePreviewReady();
- }
- }
-
- private void collapseParentView() {
- // This will effectively hide the content preview row by forcing the height
- // to zero. It is faster than forcing a relayout of the listview
- final View v = mParentView;
- int widthSpec = MeasureSpec.makeMeasureSpec(v.getWidth(), MeasureSpec.EXACTLY);
- int heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY);
- v.measure(widthSpec, heightSpec);
- v.getLayoutParams().height = 0;
- v.layout(v.getLeft(), v.getTop(), v.getRight(), v.getTop());
- v.invalidate();
- }
- }
-
- private final ChooserHandler mChooserHandler = new ChooserHandler();
-
- private class ChooserHandler extends Handler {
- private static final int LIST_VIEW_UPDATE_MESSAGE = 6;
- private static final int SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS = 7;
-
- private void removeAllMessages() {
- removeMessages(LIST_VIEW_UPDATE_MESSAGE);
- removeMessages(SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS);
- }
-
- @Override
- public void handleMessage(Message msg) {
- if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null || isDestroyed()) {
- return;
- }
-
- switch (msg.what) {
- case LIST_VIEW_UPDATE_MESSAGE:
- if (DEBUG) {
- Log.d(TAG, "LIST_VIEW_UPDATE_MESSAGE; ");
- }
-
- UserHandle userHandle = (UserHandle) msg.obj;
- mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle)
- .refreshListView();
- break;
-
- case SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS:
- if (DEBUG) Log.d(TAG, "SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS");
- final ServiceResultInfo[] resultInfos = (ServiceResultInfo[]) msg.obj;
- for (ServiceResultInfo resultInfo : resultInfos) {
- if (resultInfo.resultTargets != null) {
- ChooserListAdapter adapterForUserHandle =
- mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(
- resultInfo.userHandle);
- if (adapterForUserHandle != null) {
- adapterForUserHandle.addServiceResults(
- resultInfo.originalTarget,
- resultInfo.resultTargets, msg.arg1,
- mDirectShareShortcutInfoCache);
- }
- }
- }
-
- logDirectShareTargetReceived(
- MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER);
- sendVoiceChoicesIfNeeded();
- getChooserActivityLogger().logSharesheetDirectLoadComplete();
-
- mChooserMultiProfilePagerAdapter.getActiveListAdapter()
- .completeServiceTargetLoading();
- break;
-
- default:
- super.handleMessage(msg);
- }
- }
- };
+ public ChooserActivity() {}
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -532,168 +262,59 @@
mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET);
getChooserActivityLogger().logSharesheetTriggered();
- // This is the only place this value is being set. Effectively final.
- mIsAppPredictorComponentAvailable = isAppPredictionServiceAvailable();
- mIsSuccessfullySelected = false;
- Intent intent = getIntent();
- Parcelable targetParcelable = intent.getParcelableExtra(Intent.EXTRA_INTENT);
- if (targetParcelable instanceof Uri) {
- try {
- targetParcelable = Intent.parseUri(targetParcelable.toString(),
- Intent.URI_INTENT_SCHEME);
- } catch (URISyntaxException ex) {
- // doesn't parse as an intent; let the next test fail and error out
- }
- }
-
- if (!(targetParcelable instanceof Intent)) {
- Log.w("ChooserActivity", "Target is not an intent: " + targetParcelable);
+ try {
+ mChooserRequest = new ChooserRequestParameters(
+ getIntent(), getReferrer(), getNearbySharingComponent());
+ } catch (IllegalArgumentException e) {
+ Log.e(TAG, "Caller provided invalid Chooser request parameters", e);
finish();
- super.onCreate(null);
+ super_onCreate(null);
return;
}
- Intent target = (Intent) targetParcelable;
- if (target != null) {
- modifyTargetIntent(target);
- }
- Parcelable[] targetsParcelable
- = intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS);
- if (targetsParcelable != null) {
- final boolean offset = target == null;
- Intent[] additionalTargets =
- new Intent[offset ? targetsParcelable.length - 1 : targetsParcelable.length];
- for (int i = 0; i < targetsParcelable.length; i++) {
- if (!(targetsParcelable[i] instanceof Intent)) {
- Log.w(TAG, "EXTRA_ALTERNATE_INTENTS array entry #" + i + " is not an Intent: "
- + targetsParcelable[i]);
- finish();
- super.onCreate(null);
- return;
- }
- final Intent additionalTarget = (Intent) targetsParcelable[i];
- if (i == 0 && target == null) {
- target = additionalTarget;
- modifyTargetIntent(target);
- } else {
- additionalTargets[offset ? i - 1 : i] = additionalTarget;
- modifyTargetIntent(additionalTarget);
- }
- }
- setAdditionalTargets(additionalTargets);
- }
- mReplacementExtras = intent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS);
+ setAdditionalTargets(mChooserRequest.getAdditionalTargets());
- // Do not allow the title to be changed when sharing content
- CharSequence title = null;
- if (target != null) {
- if (!isSendAction(target)) {
- title = intent.getCharSequenceExtra(Intent.EXTRA_TITLE);
- } else {
- Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a"
- + " preview title by using EXTRA_TITLE property of the wrapped"
- + " EXTRA_INTENT.");
- }
- }
-
- int defaultTitleRes = 0;
- if (title == null) {
- defaultTitleRes = com.android.internal.R.string.chooseActivity;
- }
-
- Parcelable[] pa = intent.getParcelableArrayExtra(Intent.EXTRA_INITIAL_INTENTS);
- Intent[] initialIntents = null;
- if (pa != null) {
- int count = Math.min(pa.length, MAX_EXTRA_INITIAL_INTENTS);
- initialIntents = new Intent[count];
- for (int i = 0; i < count; i++) {
- if (!(pa[i] instanceof Intent)) {
- Log.w(TAG, "Initial intent #" + i + " not an Intent: " + pa[i]);
- finish();
- super.onCreate(null);
- return;
- }
- final Intent in = (Intent) pa[i];
- modifyTargetIntent(in);
- initialIntents[i] = in;
- }
- }
-
- mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, getReferrer());
-
- mChosenComponentSender = intent.getParcelableExtra(
- Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER);
- mRefinementIntentSender = intent.getParcelableExtra(
- Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER);
setSafeForwardingMode(true);
mPinnedSharedPrefs = getPinnedSharedPrefs(this);
- pa = intent.getParcelableArrayExtra(Intent.EXTRA_EXCLUDE_COMPONENTS);
-
-
- // Exclude out Nearby from main list if chip is present, to avoid duplication
- ComponentName nearbySharingComponent = getNearbySharingComponent();
- boolean shouldFilterNearby = !shouldNearbyShareBeFirstInRankedRow()
- && nearbySharingComponent != null;
-
- if (pa != null) {
- ComponentName[] names = new ComponentName[pa.length + (shouldFilterNearby ? 1 : 0)];
- for (int i = 0; i < pa.length; i++) {
- if (!(pa[i] instanceof ComponentName)) {
- Log.w(TAG, "Filtered component #" + i + " not a ComponentName: " + pa[i]);
- names = null;
- break;
- }
- names[i] = (ComponentName) pa[i];
- }
- if (shouldFilterNearby) {
- names[names.length - 1] = nearbySharingComponent;
- }
-
- mFilteredComponentNames = names;
- } else if (shouldFilterNearby) {
- mFilteredComponentNames = new ComponentName[1];
- mFilteredComponentNames[0] = nearbySharingComponent;
- }
-
- pa = intent.getParcelableArrayExtra(Intent.EXTRA_CHOOSER_TARGETS);
- if (pa != null) {
- int count = Math.min(pa.length, MAX_EXTRA_CHOOSER_TARGETS);
- ChooserTarget[] targets = new ChooserTarget[count];
- for (int i = 0; i < count; i++) {
- if (!(pa[i] instanceof ChooserTarget)) {
- Log.w(TAG, "Chooser target #" + i + " not a ChooserTarget: " + pa[i]);
- targets = null;
- break;
- }
- targets[i] = (ChooserTarget) pa[i];
- }
- mCallerChooserTargets = targets;
- }
-
mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row);
mShouldDisplayLandscape =
shouldDisplayLandscape(getResources().getConfiguration().orientation);
- setRetainInOnStop(intent.getBooleanExtra(EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false));
- super.onCreate(savedInstanceState, target, title, defaultTitleRes, initialIntents,
- null, false);
+ setRetainInOnStop(mChooserRequest.shouldRetainInOnStop());
+
+ createProfileRecords(
+ new AppPredictorFactory(
+ getApplicationContext(),
+ mChooserRequest.getSharedText(),
+ mChooserRequest.getTargetIntentFilter()),
+ mChooserRequest.getTargetIntentFilter());
+
+ mPreviewCoordinator = new ChooserContentPreviewCoordinator(
+ mBackgroundThreadPoolExecutor,
+ this,
+ () -> mEnterTransitionAnimationDelegate.markImagePreviewReady(false));
+
+ super.onCreate(
+ savedInstanceState,
+ mChooserRequest.getTargetIntent(),
+ mChooserRequest.getTitle(),
+ mChooserRequest.getDefaultTitleResource(),
+ mChooserRequest.getInitialIntents(),
+ /* rList: List<ResolveInfo> = */ null,
+ /* supportsAlwaysUseOption = */ false);
mChooserShownTime = System.currentTimeMillis();
final long systemCost = mChooserShownTime - intentReceivedTime;
-
- getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN)
- .setSubtype(isWorkProfile() ? MetricsEvent.MANAGED_PROFILE :
- MetricsEvent.PARENT_PROFILE)
- .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, target.getType())
- .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost));
+ getChooserActivityLogger().logChooserActivityShown(
+ isWorkProfile(), mChooserRequest.getTargetType(), systemCost);
if (mResolverDrawerLayout != null) {
mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange);
// expand/shrink direct share 4 -> 8 viewgroup
- if (isSendAction(target)) {
+ if (mChooserRequest.isSendActionTarget()) {
mResolverDrawerLayout.setOnScrollChangeListener(this::handleScroll);
}
@@ -722,26 +343,16 @@
getChooserActivityLogger().logShareStarted(
FrameworkStatsLog.SHARESHEET_STARTED,
getReferrerPackageName(),
- target.getType(),
- mCallerChooserTargets == null ? 0 : mCallerChooserTargets.length,
- initialIntents == null ? 0 : initialIntents.length,
+ mChooserRequest.getTargetType(),
+ mChooserRequest.getCallerChooserTargets().size(),
+ (mChooserRequest.getInitialIntents() == null)
+ ? 0 : mChooserRequest.getInitialIntents().length,
isWorkProfile(),
- findPreferredContentPreview(getTargetIntent(), getContentResolver()),
- target.getAction()
+ ChooserContentPreviewUi.findPreferredContentPreview(
+ getTargetIntent(), getContentResolver(), this::isImageType),
+ mChooserRequest.getTargetAction()
);
- mDirectShareShortcutInfoCache = new HashMap<>();
- setEnterSharedElementCallback(new SharedElementCallback() {
- @Override
- public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) {
- if (mRemoveSharedElements) {
- names.remove(FIRST_IMAGE_PREVIEW_TRANSITION_NAME);
- sharedElements.remove(FIRST_IMAGE_PREVIEW_TRANSITION_NAME);
- }
- super.onMapSharedElements(names, sharedElements);
- mRemoveSharedElements = false;
- }
- });
mEnterTransitionAnimationDelegate.postponeTransition();
}
@@ -750,52 +361,51 @@
return R.style.Theme_DeviceDefault_Chooser;
}
- private AppPredictor setupAppPredictorForUser(UserHandle userHandle,
- AppPredictor.Callback appPredictorCallback) {
- AppPredictor appPredictor = getAppPredictorForDirectShareIfEnabled(userHandle);
- if (appPredictor == null) {
- return null;
+ private void createProfileRecords(
+ AppPredictorFactory factory, IntentFilter targetIntentFilter) {
+ UserHandle mainUserHandle = getPersonalProfileUserHandle();
+ createProfileRecord(mainUserHandle, targetIntentFilter, factory);
+
+ UserHandle workUserHandle = getWorkProfileUserHandle();
+ if (workUserHandle != null) {
+ createProfileRecord(workUserHandle, targetIntentFilter, factory);
}
- mDirectShareAppTargetCache = new HashMap<>();
- appPredictor.registerPredictionUpdates(this.getMainExecutor(), appPredictorCallback);
- return appPredictor;
}
- private AppPredictor.Callback createAppPredictorCallback(
- ChooserListAdapter chooserListAdapter) {
- return resultList -> {
- if (isFinishing() || isDestroyed()) {
- return;
- }
- if (chooserListAdapter.getCount() == 0) {
- return;
- }
- if (resultList.isEmpty()
- && shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) {
- // APS may be disabled, so try querying targets ourselves.
- queryDirectShareTargets(chooserListAdapter, true);
- return;
- }
- final List<ShortcutManager.ShareShortcutInfo> shareShortcutInfos =
- new ArrayList<>();
+ private void createProfileRecord(
+ UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) {
+ AppPredictor appPredictor = factory.create(userHandle);
+ ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic()
+ ? null
+ : createShortcutLoader(
+ getApplicationContext(),
+ appPredictor,
+ userHandle,
+ targetIntentFilter,
+ shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult));
+ mProfileRecords.put(
+ userHandle.getIdentifier(),
+ new ProfileRecord(appPredictor, shortcutLoader));
+ }
- List<AppTarget> shortcutResults = new ArrayList<>();
- for (AppTarget appTarget : resultList) {
- if (appTarget.getShortcutInfo() == null) {
- continue;
- }
- shortcutResults.add(appTarget);
- }
- resultList = shortcutResults;
- for (AppTarget appTarget : resultList) {
- shareShortcutInfos.add(new ShortcutManager.ShareShortcutInfo(
- appTarget.getShortcutInfo(),
- new ComponentName(
- appTarget.getPackageName(), appTarget.getClassName())));
- }
- sendShareShortcutInfoList(shareShortcutInfos, chooserListAdapter, resultList,
- chooserListAdapter.getUserHandle());
- };
+ @Nullable
+ private ProfileRecord getProfileRecord(UserHandle userHandle) {
+ return mProfileRecords.get(userHandle.getIdentifier(), null);
+ }
+
+ @VisibleForTesting
+ protected ShortcutLoader createShortcutLoader(
+ Context context,
+ AppPredictor appPredictor,
+ UserHandle userHandle,
+ IntentFilter targetIntentFilter,
+ Consumer<ShortcutLoader.Result> callback) {
+ return new ShortcutLoader(
+ context,
+ appPredictor,
+ userHandle,
+ targetIntentFilter,
+ callback);
}
static SharedPreferences getPinnedSharedPrefs(Context context) {
@@ -829,6 +439,41 @@
return mChooserMultiProfilePagerAdapter;
}
+ @Override
+ protected EmptyStateProvider createBlockerEmptyStateProvider() {
+ final boolean isSendAction = mChooserRequest.isSendActionTarget();
+
+ final EmptyState noWorkToPersonalEmptyState =
+ new DevicePolicyBlockerEmptyState(
+ /* context= */ this,
+ /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
+ /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked,
+ /* devicePolicyStringSubtitleId= */
+ isSendAction ? RESOLVER_CANT_SHARE_WITH_PERSONAL : RESOLVER_CANT_ACCESS_PERSONAL,
+ /* defaultSubtitleResource= */
+ isSendAction ? R.string.resolver_cant_share_with_personal_apps_explanation
+ : R.string.resolver_cant_access_personal_apps_explanation,
+ /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL,
+ /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER);
+
+ final EmptyState noPersonalToWorkEmptyState =
+ new DevicePolicyBlockerEmptyState(
+ /* context= */ this,
+ /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
+ /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked,
+ /* devicePolicyStringSubtitleId= */
+ isSendAction ? RESOLVER_CANT_SHARE_WITH_WORK : RESOLVER_CANT_ACCESS_WORK,
+ /* defaultSubtitleResource= */
+ isSendAction ? R.string.resolver_cant_share_with_work_apps_explanation
+ : R.string.resolver_cant_access_work_apps_explanation,
+ /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK,
+ /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER);
+
+ return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(),
+ noWorkToPersonalEmptyState, noPersonalToWorkEmptyState,
+ createCrossProfileIntentsChecker(), createMyUserIdProvider());
+ }
+
private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile(
Intent[] initialIntents,
List<ResolveInfo> rList,
@@ -843,9 +488,10 @@
return new ChooserMultiProfilePagerAdapter(
/* context */ this,
adapter,
- getPersonalProfileUserHandle(),
+ createEmptyStateProvider(/* workProfileUserHandle= */ null),
+ mQuietModeManager,
/* workProfileUserHandle= */ null,
- isSendAction(getTargetIntent()), mMaxTargetsPerRow);
+ mMaxTargetsPerRow);
}
private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles(
@@ -871,10 +517,11 @@
/* context */ this,
personalAdapter,
workAdapter,
+ createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()),
+ mQuietModeManager,
selectedProfile,
- getPersonalProfileUserHandle(),
getWorkProfileUserHandle(),
- isSendAction(getTargetIntent()), mMaxTargetsPerRow);
+ mMaxTargetsPerRow);
}
private int findSelectedProfile() {
@@ -891,19 +538,14 @@
if (shouldShowStickyContentPreview()
|| mChooserMultiProfilePagerAdapter
.getCurrentRootAdapter().getSystemRowCount() != 0) {
- logActionShareWithPreview();
+ getChooserActivityLogger().logActionShareWithPreview(
+ ChooserContentPreviewUi.findPreferredContentPreview(
+ getTargetIntent(), getContentResolver(), this::isImageType));
}
return postRebuildListInternal(rebuildCompleted);
}
/**
- * Returns true if app prediction service is defined and the component exists on device.
- */
- private boolean isAppPredictionServiceAvailable() {
- return getPackageManager().getAppPredictionServicePackageName() != null;
- }
-
- /**
* Check if the profile currently used is a work profile.
* @return true if it is work profile, false if it is parent profile (or no work profile is
* set up)
@@ -949,7 +591,7 @@
updateProfileViewButton();
}
- private void onCopyButtonClicked(View v) {
+ private void onCopyButtonClicked() {
Intent targetIntent = getTargetIntent();
if (targetIntent == null) {
finish();
@@ -987,15 +629,7 @@
Context.CLIPBOARD_SERVICE);
clipboardManager.setPrimaryClipAsPackage(clipData, getReferrerPackageName());
- // Log share completion via copy
- LogMaker targetLogMaker = new LogMaker(
- MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET).setSubtype(1);
- getMetricsLogger().write(targetLogMaker);
- getChooserActivityLogger().logShareTargetSelected(
- SELECTION_TYPE_COPY,
- "",
- -1,
- false);
+ getChooserActivityLogger().logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY);
setResult(RESULT_OK);
finish();
@@ -1068,10 +702,59 @@
}
}
- private ViewGroup createContentPreviewView(ViewGroup parent) {
+ /**
+ * Create a view that will be shown in the content preview area
+ * @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 = findPreferredContentPreview(targetIntent, getContentResolver());
- return displayContentPreview(previewType, targetIntent, getLayoutInflater(), parent);
+ 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,
+ getResources(),
+ getLayoutInflater(),
+ actionFactory,
+ R.layout.chooser_action_row,
+ parent,
+ previewCoordinator,
+ mEnterTransitionAnimationDelegate::markImagePreviewReady,
+ getContentResolver(),
+ this::isImageType);
+
+ if (layout != null) {
+ adjustPreviewWidth(getResources().getConfiguration().orientation, layout);
+ }
+ if (previewType != ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE) {
+ mEnterTransitionAnimationDelegate.markImagePreviewReady(false);
+ }
+
+ return layout;
}
@VisibleForTesting
@@ -1108,6 +791,19 @@
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) {
@@ -1116,9 +812,15 @@
return null;
}
- final DisplayResolveInfo dri = new DisplayResolveInfo(
- originalIntent, ri, getString(com.android.internal.R.string.screenshot_edit), "", resolveIntent, null);
- dri.setDisplayIcon(getDrawable(com.android.internal.R.drawable.ic_screenshot_edit));
+ 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;
}
@@ -1160,70 +862,55 @@
icon = ri.loadIcon(getPackageManager());
}
- final DisplayResolveInfo dri = new DisplayResolveInfo(
+ final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
originalIntent, ri, name, "", resolveIntent, null);
- dri.setDisplayIcon(icon);
+ dri.getDisplayIconHolder().setDisplayIcon(icon);
return dri;
}
- private Button createActionButton(Drawable icon, CharSequence title, View.OnClickListener r) {
- Button b = (Button) LayoutInflater.from(this).inflate(R.layout.chooser_action_button, null);
- if (icon != null) {
- final int size = getResources()
- .getDimensionPixelSize(R.dimen.chooser_action_button_icon_size);
- icon.setBounds(0, 0, size, size);
- b.setCompoundDrawablesRelative(icon, null, null, null);
- }
- b.setText(title);
- b.setOnClickListener(r);
- return b;
- }
-
- private Button createCopyButton() {
- final Button b = createActionButton(
+ 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),
- getString(com.android.internal.R.string.copy), this::onCopyButtonClicked);
- b.setId(com.android.internal.R.id.chooser_copy_button);
- return b;
+ this::onCopyButtonClicked);
}
- private @Nullable Button createNearbyButton(Intent originalIntent) {
+ @Nullable
+ private ActionRow.Action createNearbyAction(Intent originalIntent) {
final TargetInfo ti = getNearbySharingTarget(originalIntent);
- if (ti == null) return null;
+ if (ti == null) {
+ return null;
+ }
- final Button b = createActionButton(
- ti.getDisplayIcon(this),
+ return new ActionRow.Action(
+ com.android.internal.R.id.chooser_nearby_button,
ti.getDisplayLabel(),
- (View unused) -> {
- // Log share completion via nearby
- getChooserActivityLogger().logShareTargetSelected(
- SELECTION_TYPE_NEARBY,
- "",
- -1,
- false);
+ ti.getDisplayIconHolder().getDisplayIcon(),
+ () -> {
+ getChooserActivityLogger().logActionSelected(
+ ChooserActivityLogger.SELECTION_TYPE_NEARBY);
// Action bar is user-independent, always start as primary
safelyStartActivityAsUser(ti, getPersonalProfileUserHandle());
finish();
- }
- );
- b.setId(com.android.internal.R.id.chooser_nearby_button);
- return b;
+ });
}
- private @Nullable Button createEditButton(Intent originalIntent) {
+ @Nullable
+ private ActionRow.Action createEditAction(Intent originalIntent) {
final TargetInfo ti = getEditSharingTarget(originalIntent);
- if (ti == null) return null;
+ if (ti == null) {
+ return null;
+ }
- final Button b = createActionButton(
- ti.getDisplayIcon(this),
+ return new ActionRow.Action(
+ com.android.internal.R.id.chooser_edit_button,
ti.getDisplayLabel(),
- (View unused) -> {
+ ti.getDisplayIconHolder().getDisplayIcon(),
+ () -> {
// Log share completion via edit
- getChooserActivityLogger().logShareTargetSelected(
- SELECTION_TYPE_EDIT,
- "",
- -1,
- false);
+ getChooserActivityLogger().logActionSelected(
+ ChooserActivityLogger.SELECTION_TYPE_EDIT);
View firstImgView = getFirstVisibleImgPreviewView();
// Action bar is user-independent, always start as primary
if (firstImgView == null) {
@@ -1238,8 +925,6 @@
}
}
);
- b.setId(com.android.internal.R.id.chooser_edit_button);
- return b;
}
@Nullable
@@ -1248,165 +933,6 @@
return firstImage != null && firstImage.isVisibleToUser() ? firstImage : null;
}
- private void addActionButton(ViewGroup parent, Button b) {
- if (b == null) return;
- final ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams(
- LayoutParams.WRAP_CONTENT,
- LayoutParams.WRAP_CONTENT
- );
- final int gap = getResources().getDimensionPixelSize(R.dimen.resolver_icon_margin) / 2;
- lp.setMarginsRelative(gap, 0, gap, 0);
- parent.addView(b, lp);
- }
-
- private ViewGroup displayContentPreview(@ContentPreviewType int previewType,
- Intent targetIntent, LayoutInflater layoutInflater, ViewGroup parent) {
- ViewGroup layout = null;
-
- switch (previewType) {
- case CONTENT_PREVIEW_TEXT:
- layout = displayTextContentPreview(targetIntent, layoutInflater, parent);
- break;
- case CONTENT_PREVIEW_IMAGE:
- layout = displayImageContentPreview(targetIntent, layoutInflater, parent);
- break;
- case CONTENT_PREVIEW_FILE:
- layout = displayFileContentPreview(targetIntent, layoutInflater, parent);
- break;
- default:
- Log.e(TAG, "Unexpected content preview type: " + previewType);
- }
-
- if (layout != null) {
- adjustPreviewWidth(getResources().getConfiguration().orientation, layout);
- }
- if (previewType != CONTENT_PREVIEW_IMAGE) {
- mEnterTransitionAnimationDelegate.markImagePreviewReady();
- }
-
- return layout;
- }
-
- private ViewGroup displayTextContentPreview(Intent targetIntent, LayoutInflater layoutInflater,
- ViewGroup parent) {
- ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
- R.layout.chooser_grid_preview_text, parent, false);
-
- final ViewGroup actionRow =
- (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row);
- addActionButton(actionRow, createCopyButton());
- if (shouldNearbyShareBeIncludedAsActionButton()) {
- addActionButton(actionRow, createNearbyButton(targetIntent));
- }
-
- 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 {
- mPreviewCoord = new ContentPreviewCoordinator(contentPreviewLayout, false);
- mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_thumbnail, previewThumbnail, 0);
- }
- }
-
- return contentPreviewLayout;
- }
-
- private ViewGroup displayImageContentPreview(Intent targetIntent, LayoutInflater layoutInflater,
- ViewGroup parent) {
- ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
- R.layout.chooser_grid_preview_image, parent, false);
- ViewGroup imagePreview = contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_image_area);
-
- final ViewGroup actionRow =
- (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row);
- //TODO: addActionButton(actionRow, createCopyButton());
- if (shouldNearbyShareBeIncludedAsActionButton()) {
- addActionButton(actionRow, createNearbyButton(targetIntent));
- }
- addActionButton(actionRow, createEditButton(targetIntent));
-
- mPreviewCoord = new ContentPreviewCoordinator(contentPreviewLayout, false);
-
- String action = targetIntent.getAction();
- if (Intent.ACTION_SEND.equals(action)) {
- Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
- imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large)
- .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME);
- mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, uri, 0);
- } else {
- ContentResolver resolver = getContentResolver();
-
- List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
- List<Uri> imageUris = new ArrayList<>();
- for (Uri uri : uris) {
- if (isImageType(resolver.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);
- return contentPreviewLayout;
- }
-
- imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large)
- .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME);
- mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, imageUris.get(0), 0);
-
- if (imageUris.size() == 2) {
- mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_large,
- imageUris.get(1), 0);
- } else if (imageUris.size() > 2) {
- mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_small,
- imageUris.get(1), 0);
- mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_3_small,
- imageUris.get(2), imageUris.size() - 3);
- }
- }
-
- return contentPreviewLayout;
- }
-
- private static class FileInfo {
- public final String name;
- public final boolean hasThumbnail;
-
- FileInfo(String name, boolean hasThumbnail) {
- this.name = name;
- this.hasThumbnail = hasThumbnail;
- }
- }
-
/**
* Wrapping the ContentResolver call to expose for easier mocking,
* and to avoid mocking Android core classes.
@@ -1416,175 +942,11 @@
return resolver.query(uri, null, null, null, null);
}
- private 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 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 ViewGroup displayFileContentPreview(Intent targetIntent, LayoutInflater layoutInflater,
- ViewGroup parent) {
-
- ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate(
- R.layout.chooser_grid_preview_file, parent, false);
-
- final ViewGroup actionRow =
- (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row);
- //TODO(b/120417119): addActionButton(actionRow, createCopyButton());
- if (shouldNearbyShareBeIncludedAsActionButton()) {
- addActionButton(actionRow, createNearbyButton(targetIntent));
- }
-
- String action = targetIntent.getAction();
- if (Intent.ACTION_SEND.equals(action)) {
- Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
- loadFileUriIntoView(uri, contentPreviewLayout);
- } 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);
- } else {
- FileInfo fileInfo = extractFileInfo(uris.get(0), getContentResolver());
- 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(
- getResources(),
- 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 void loadFileUriIntoView(final Uri uri, final View parent) {
- FileInfo fileInfo = extractFileInfo(uri, getContentResolver());
-
- TextView fileNameView = parent.findViewById(com.android.internal.R.id.content_preview_filename);
- fileNameView.setText(fileInfo.name);
-
- if (fileInfo.hasThumbnail) {
- mPreviewCoord = new ContentPreviewCoordinator(parent, false);
- mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_file_thumbnail, uri, 0);
- } 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);
- }
- }
-
@VisibleForTesting
protected boolean isImageType(String mimeType) {
return mimeType != null && mimeType.startsWith("image/");
}
- @ContentPreviewType
- private int findPreferredContentPreview(Uri uri, ContentResolver resolver) {
- if (uri == null) {
- return CONTENT_PREVIEW_TEXT;
- }
-
- String mimeType = resolver.getType(uri);
- return isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE;
- }
-
- /**
- * 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
- * the preferred type, in order of IMAGE, FILE, TEXT.
- */
- @ContentPreviewType
- private int findPreferredContentPreview(Intent targetIntent, ContentResolver resolver) {
- String action = targetIntent.getAction();
- if (Intent.ACTION_SEND.equals(action)) {
- Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
- return findPreferredContentPreview(uri, resolver);
- } 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
- if (findPreferredContentPreview(uri, resolver) == CONTENT_PREVIEW_FILE) {
- return CONTENT_PREVIEW_FILE;
- }
- }
-
- return CONTENT_PREVIEW_IMAGE;
- }
-
- return CONTENT_PREVIEW_TEXT;
- }
-
private int getNumSheetExpansions() {
return getPreferences(Context.MODE_PRIVATE).getInt(PREF_NUM_SHEET_EXPANSIONS, 0);
}
@@ -1614,23 +976,29 @@
mRefinementResultReceiver.destroy();
mRefinementResultReceiver = null;
}
- mChooserHandler.removeAllMessages();
- if (mPreviewCoord != null) mPreviewCoord.cancelLoads();
+ mBackgroundThreadPoolExecutor.shutdownNow();
- mChooserMultiProfilePagerAdapter.getActiveListAdapter().destroyAppPredictor();
- if (mChooserMultiProfilePagerAdapter.getInactiveListAdapter() != null) {
- mChooserMultiProfilePagerAdapter.getInactiveListAdapter().destroyAppPredictor();
+ destroyProfileRecords();
+ }
+
+ private void destroyProfileRecords() {
+ for (int i = 0; i < mProfileRecords.size(); ++i) {
+ mProfileRecords.valueAt(i).destroy();
}
- mPersonalAppPredictor = null;
- mWorkAppPredictor = null;
+ mProfileRecords.clear();
}
@Override // ResolverListCommunicator
public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) {
+ if (mChooserRequest == null) {
+ return defIntent;
+ }
+
Intent result = defIntent;
- if (mReplacementExtras != null) {
- final Bundle replExtras = mReplacementExtras.getBundle(aInfo.packageName);
+ if (mChooserRequest.getReplacementExtras() != null) {
+ final Bundle replExtras =
+ mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName);
if (replExtras != null) {
result = new Intent(defIntent);
result.putExtras(replExtras);
@@ -1651,12 +1019,13 @@
@Override
public void onActivityStarted(TargetInfo cti) {
- if (mChosenComponentSender != null) {
+ if (mChooserRequest.getChosenComponentSender() != null) {
final ComponentName target = cti.getResolvedComponentName();
if (target != null) {
final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target);
try {
- mChosenComponentSender.sendIntent(this, Activity.RESULT_OK, fillIn, null, null);
+ mChooserRequest.getChosenComponentSender().sendIntent(
+ this, Activity.RESULT_OK, fillIn, null, null);
} catch (IntentSender.SendIntentException e) {
Slog.e(TAG, "Unable to launch supplied IntentSender to report "
+ "the chosen component: " + e);
@@ -1667,12 +1036,13 @@
@Override
public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) {
- if (mCallerChooserTargets != null && mCallerChooserTargets.length > 0) {
+ if (mChooserRequest.getCallerChooserTargets().size() > 0) {
mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults(
/* origTarget */ null,
- Lists.newArrayList(mCallerChooserTargets),
+ new ArrayList<>(mChooserRequest.getCallerChooserTargets()),
TARGET_TYPE_DEFAULT,
- /* directShareShortcutInfoCache */ null);
+ /* directShareShortcutInfoCache */ Collections.emptyMap(),
+ /* directShareAppTargetCache */ Collections.emptyMap());
}
}
@@ -1701,57 +1071,34 @@
private void showTargetDetails(TargetInfo targetInfo) {
if (targetInfo == null) return;
- ArrayList<DisplayResolveInfo> targetList;
- ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment();
- Bundle bundle = new Bundle();
-
- if (targetInfo instanceof SelectableTargetInfo) {
- SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo;
- if (selectableTargetInfo.getDisplayResolveInfo() == null
- || selectableTargetInfo.getChooserTarget() == null) {
- Log.e(TAG, "displayResolveInfo or chooserTarget in selectableTargetInfo are null");
- return;
- }
- targetList = new ArrayList<>();
- targetList.add(selectableTargetInfo.getDisplayResolveInfo());
- bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY,
- selectableTargetInfo.getChooserTarget().getIntentExtras().getString(
- Intent.EXTRA_SHORTCUT_ID));
- bundle.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY,
- selectableTargetInfo.isPinned());
- bundle.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY,
- getTargetIntentFilter());
- if (selectableTargetInfo.getDisplayLabel() != null) {
- bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY,
- selectableTargetInfo.getDisplayLabel().toString());
- }
- } else if (targetInfo instanceof MultiDisplayResolveInfo) {
- // For multiple targets, include info on all targets
- MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo;
- targetList = mti.getTargets();
- } else {
- targetList = new ArrayList<DisplayResolveInfo>();
- targetList.add((DisplayResolveInfo) targetInfo);
+ List<DisplayResolveInfo> targetList = targetInfo.getAllDisplayTargets();
+ if (targetList.isEmpty()) {
+ Log.e(TAG, "No displayable data to show target details");
+ return;
}
- bundle.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY,
- mChooserMultiProfilePagerAdapter.getCurrentUserHandle());
- bundle.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY,
- targetList);
- fragment.setArguments(bundle);
- fragment.show(getFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG);
- }
+ // TODO: implement these type-conditioned behaviors polymorphically, and consider moving
+ // the logic into `ChooserTargetActionsDialogFragment.show()`.
+ boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned();
+ IntentFilter intentFilter = targetInfo.isSelectableTargetInfo()
+ ? mChooserRequest.getTargetIntentFilter() : null;
+ String shortcutTitle = targetInfo.isSelectableTargetInfo()
+ ? targetInfo.getDisplayLabel().toString() : null;
+ String shortcutIdKey = targetInfo.getDirectShareShortcutId();
- private void modifyTargetIntent(Intent in) {
- if (isSendAction(in)) {
- in.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT |
- Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
- }
+ ChooserTargetActionsDialogFragment.show(
+ getSupportFragmentManager(),
+ targetList,
+ mChooserMultiProfilePagerAdapter.getCurrentUserHandle(),
+ shortcutIdKey,
+ shortcutTitle,
+ isShortcutPinned,
+ intentFilter);
}
@Override
protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) {
- if (mRefinementIntentSender != null) {
+ if (mChooserRequest.getRefinementIntentSender() != null) {
final Intent fillIn = new Intent();
final List<Intent> sourceIntents = target.getAllSourceIntents();
if (!sourceIntents.isEmpty()) {
@@ -1770,7 +1117,8 @@
fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER,
mRefinementResultReceiver);
try {
- mRefinementIntentSender.sendIntent(this, 0, fillIn, null, null);
+ mChooserRequest.getRefinementIntentSender().sendIntent(
+ this, 0, fillIn, null, null);
return false;
} catch (SendIntentException e) {
Log.e(TAG, "Refinement IntentSender failed to send", e);
@@ -1787,25 +1135,20 @@
mChooserMultiProfilePagerAdapter.getActiveListAdapter();
TargetInfo targetInfo = currentListAdapter
.targetInfoForPosition(which, filtered);
- if (targetInfo != null && targetInfo instanceof NotSelectableTargetInfo) {
+ if (targetInfo != null && targetInfo.isNotSelectableTargetInfo()) {
return;
}
final long selectionCost = System.currentTimeMillis() - mChooserShownTime;
- if (targetInfo instanceof MultiDisplayResolveInfo) {
+ if (targetInfo.isMultiDisplayResolveInfo()) {
MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo;
if (!mti.hasSelected()) {
- ChooserStackedAppDialogFragment f = new ChooserStackedAppDialogFragment();
- Bundle b = new Bundle();
- b.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY,
+ ChooserStackedAppDialogFragment.show(
+ getSupportFragmentManager(),
+ mti,
+ which,
mChooserMultiProfilePagerAdapter.getCurrentUserHandle());
- b.putObject(ChooserStackedAppDialogFragment.MULTI_DRI_KEY,
- mti);
- b.putInt(ChooserStackedAppDialogFragment.WHICH_KEY, which);
- f.setArguments(b);
-
- f.show(getFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG);
return;
}
}
@@ -1813,103 +1156,65 @@
super.startSelected(which, always, filtered);
if (currentListAdapter.getCount() > 0) {
- // Log the index of which type of target the user picked.
- // Lower values mean the ranking was better.
- int cat = 0;
- int value = which;
- int directTargetAlsoRanked = -1;
- int numCallerProvided = 0;
- HashedStringCache.HashResult directTargetHashed = null;
switch (currentListAdapter.getPositionTargetType(which)) {
case ChooserListAdapter.TARGET_SERVICE:
- cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET;
- // Log the package name + target name to answer the question if most users
- // share to mostly the same person or to a bunch of different people.
- ChooserTarget target = currentListAdapter.getChooserTargetForValue(value);
- directTargetHashed = HashedStringCache.getInstance().hashString(
- this,
- TAG,
- target.getComponentName().getPackageName()
- + target.getTitle().toString(),
- mMaxHashSaltDays);
- SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo;
- directTargetAlsoRanked = getRankedPosition(selectableTargetInfo);
-
- if (mCallerChooserTargets != null) {
- numCallerProvided = mCallerChooserTargets.length;
- }
getChooserActivityLogger().logShareTargetSelected(
- SELECTION_TYPE_SERVICE,
+ ChooserActivityLogger.SELECTION_TYPE_SERVICE,
targetInfo.getResolveInfo().activityInfo.processName,
- value,
- selectableTargetInfo.isPinned()
+ which,
+ /* directTargetAlsoRanked= */ getRankedPosition(targetInfo),
+ mChooserRequest.getCallerChooserTargets().size(),
+ targetInfo.getHashedTargetIdForMetrics(this),
+ targetInfo.isPinned(),
+ mIsSuccessfullySelected,
+ selectionCost
);
- break;
+ return;
case ChooserListAdapter.TARGET_CALLER:
case ChooserListAdapter.TARGET_STANDARD:
- cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET;
- value -= currentListAdapter.getSurfacedTargetInfo().size();
- numCallerProvided = currentListAdapter.getCallerTargetCount();
getChooserActivityLogger().logShareTargetSelected(
- SELECTION_TYPE_APP,
+ ChooserActivityLogger.SELECTION_TYPE_APP,
targetInfo.getResolveInfo().activityInfo.processName,
- value,
- targetInfo.isPinned()
+ (which - currentListAdapter.getSurfacedTargetInfo().size()),
+ /* directTargetAlsoRanked= */ -1,
+ currentListAdapter.getCallerTargetCount(),
+ /* directTargetHashed= */ null,
+ targetInfo.isPinned(),
+ mIsSuccessfullySelected,
+ selectionCost
);
- break;
+ return;
case ChooserListAdapter.TARGET_STANDARD_AZ:
- // A-Z targets are unranked standard targets; we use -1 to mark that they
- // are from the alphabetical pool.
- value = -1;
- cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET;
+ // A-Z targets are unranked standard targets; we use a value of -1 to mark that
+ // they are from the alphabetical pool.
+ // TODO: why do we log a different selection type if the -1 value already
+ // designates the same condition?
getChooserActivityLogger().logShareTargetSelected(
- SELECTION_TYPE_STANDARD,
+ ChooserActivityLogger.SELECTION_TYPE_STANDARD,
targetInfo.getResolveInfo().activityInfo.processName,
- value,
- false
+ /* value= */ -1,
+ /* directTargetAlsoRanked= */ -1,
+ /* numCallerProvided= */ 0,
+ /* directTargetHashed= */ null,
+ /* isPinned= */ false,
+ mIsSuccessfullySelected,
+ selectionCost
);
- break;
- }
-
- if (cat != 0) {
- LogMaker targetLogMaker = new LogMaker(cat).setSubtype(value);
- if (directTargetHashed != null) {
- targetLogMaker.addTaggedData(
- MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString);
- targetLogMaker.addTaggedData(
- MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN,
- directTargetHashed.saltGeneration);
- targetLogMaker.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION,
- directTargetAlsoRanked);
- }
- targetLogMaker.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED,
- numCallerProvided);
- getMetricsLogger().write(targetLogMaker);
- }
-
- if (mIsSuccessfullySelected) {
- if (DEBUG) {
- Log.d(TAG, "User Selection Time Cost is " + selectionCost);
- Log.d(TAG, "position of selected app/service/caller is " +
- Integer.toString(value));
- }
- MetricsLogger.histogram(null, "user_selection_cost_for_smart_sharing",
- (int) selectionCost);
- MetricsLogger.histogram(null, "app_position_for_smart_sharing", value);
+ return;
}
}
}
- private int getRankedPosition(SelectableTargetInfo targetInfo) {
+ private int getRankedPosition(TargetInfo targetInfo) {
String targetPackageName =
- targetInfo.getChooserTarget().getComponentName().getPackageName();
+ targetInfo.getChooserTargetComponentName().getPackageName();
ChooserListAdapter currentListAdapter =
mChooserMultiProfilePagerAdapter.getActiveListAdapter();
- int maxRankedResults = Math.min(currentListAdapter.mDisplayList.size(),
- MAX_LOG_RANK_POSITION);
+ int maxRankedResults = Math.min(
+ currentListAdapter.getDisplayResolveInfoCount(), MAX_LOG_RANK_POSITION);
for (int i = 0; i < maxRankedResults; i++) {
- if (currentListAdapter.mDisplayList.get(i)
+ if (currentListAdapter.getDisplayResolveInfo(i)
.getResolveInfo().activityInfo.packageName.equals(targetPackageName)) {
return i;
}
@@ -1933,8 +1238,11 @@
}
private IntentFilter getTargetIntentFilter() {
+ return getTargetIntentFilter(getTargetIntent());
+ }
+
+ private IntentFilter getTargetIntentFilter(final Intent intent) {
try {
- final Intent intent = getTargetIntent();
String dataString = intent.getDataString();
if (intent.getType() == null) {
if (!TextUtils.isEmpty(dataString)) {
@@ -1968,218 +1276,18 @@
}
}
- @VisibleForTesting
- protected void queryDirectShareTargets(
- ChooserListAdapter adapter, boolean skipAppPredictionService) {
- mQueriedSharingShortcutsTimeMs = System.currentTimeMillis();
- UserHandle userHandle = adapter.getUserHandle();
- if (!skipAppPredictionService) {
- AppPredictor appPredictor = getAppPredictorForDirectShareIfEnabled(userHandle);
- if (appPredictor != null) {
- appPredictor.requestPredictionUpdate();
- return;
- }
- }
- // Default to just querying ShortcutManager if AppPredictor not present.
- final IntentFilter filter = getTargetIntentFilter();
- if (filter == null) {
+ private void logDirectShareTargetReceived(UserHandle forUser) {
+ ProfileRecord profileRecord = getProfileRecord(forUser);
+ if (profileRecord == null) {
return;
}
-
- AsyncTask.execute(() -> {
- Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */);
- ShortcutManager sm = (ShortcutManager) selectedProfileContext
- .getSystemService(Context.SHORTCUT_SERVICE);
- List<ShortcutManager.ShareShortcutInfo> resultList = sm.getShareTargets(filter);
- sendShareShortcutInfoList(resultList, adapter, null, userHandle);
- });
- }
-
- /**
- * Returns {@code false} if {@code userHandle} is the work profile and it's either
- * in quiet mode or not running.
- */
- private boolean shouldQueryShortcutManager(UserHandle userHandle) {
- if (!shouldShowTabs()) {
- return true;
- }
- if (!getWorkProfileUserHandle().equals(userHandle)) {
- return true;
- }
- if (!isUserRunning(userHandle)) {
- return false;
- }
- if (!isUserUnlocked(userHandle)) {
- return false;
- }
- if (isQuietModeEnabled(userHandle)) {
- return false;
- }
- return true;
- }
-
- private void sendShareShortcutInfoList(
- List<ShortcutManager.ShareShortcutInfo> resultList,
- ChooserListAdapter chooserListAdapter,
- @Nullable List<AppTarget> appTargets, UserHandle userHandle) {
- if (appTargets != null && appTargets.size() != resultList.size()) {
- throw new RuntimeException("resultList and appTargets must have the same size."
- + " resultList.size()=" + resultList.size()
- + " appTargets.size()=" + appTargets.size());
- }
- Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */);
- for (int i = resultList.size() - 1; i >= 0; i--) {
- final String packageName = resultList.get(i).getTargetComponent().getPackageName();
- if (!isPackageEnabled(selectedProfileContext, packageName)) {
- resultList.remove(i);
- if (appTargets != null) {
- appTargets.remove(i);
- }
- }
- }
-
- // If |appTargets| is not null, results are from AppPredictionService and already sorted.
- final int shortcutType = (appTargets == null ? TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER :
- TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE);
-
- // 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.
- List<ServiceResultInfo> resultRecords = new ArrayList<>();
- for (int i = 0; i < chooserListAdapter.getDisplayResolveInfoCount(); i++) {
- DisplayResolveInfo displayResolveInfo = chooserListAdapter.getDisplayResolveInfo(i);
- List<ShortcutManager.ShareShortcutInfo> matchingShortcuts =
- filterShortcutsByTargetComponentName(
- resultList, displayResolveInfo.getResolvedComponentName());
- if (matchingShortcuts.isEmpty()) {
- continue;
- }
- List<ChooserTarget> chooserTargets = convertToChooserTarget(
- matchingShortcuts, resultList, appTargets, shortcutType);
-
- ServiceResultInfo resultRecord = new ServiceResultInfo(
- displayResolveInfo, chooserTargets, userHandle);
- resultRecords.add(resultRecord);
- }
-
- sendShortcutManagerShareTargetResults(
- shortcutType, resultRecords.toArray(new ServiceResultInfo[0]));
- }
-
- private 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 void sendShortcutManagerShareTargetResults(
- int shortcutType, ServiceResultInfo[] results) {
- final Message msg = Message.obtain();
- msg.what = ChooserHandler.SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS;
- msg.obj = results;
- msg.arg1 = shortcutType;
- mChooserHandler.sendMessage(msg);
- }
-
- private boolean isPackageEnabled(Context context, String packageName) {
- if (TextUtils.isEmpty(packageName)) {
- return false;
- }
- ApplicationInfo appInfo;
- try {
- appInfo = context.getPackageManager().getApplicationInfo(packageName, 0);
- } catch (NameNotFoundException e) {
- return false;
- }
-
- if (appInfo != null && appInfo.enabled
- && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0) {
- return true;
- }
- return false;
- }
-
- /**
- * Converts a list of ShareShortcutInfos to ChooserTargets.
- * @param matchingShortcuts List of shortcuts, all from the same package, that match the current
- * share intent filter.
- * @param allShortcuts List of all the shortcuts from all the packages on the device that are
- * returned for the current sharing action.
- * @param allAppTargets List of AppTargets. Null if the results are not from prediction service.
- * @param shortcutType One of the values TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER or
- * TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE
- * @return A list of ChooserTargets sorted by score in descending order.
- */
- @VisibleForTesting
- @NonNull
- public List<ChooserTarget> convertToChooserTarget(
- @NonNull List<ShortcutManager.ShareShortcutInfo> matchingShortcuts,
- @NonNull List<ShortcutManager.ShareShortcutInfo> allShortcuts,
- @Nullable List<AppTarget> allAppTargets, @ShareTargetType int shortcutType) {
- // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted
- // list instead of the actual rank value when converting a rank to a score.
- List<Integer> scoreList = new ArrayList<>();
- if (shortcutType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER) {
- for (int i = 0; i < matchingShortcuts.size(); i++) {
- int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank();
- if (!scoreList.contains(shortcutRank)) {
- scoreList.add(shortcutRank);
- }
- }
- Collections.sort(scoreList);
- }
-
- List<ChooserTarget> chooserTargetList = new ArrayList<>(matchingShortcuts.size());
- for (int i = 0; i < matchingShortcuts.size(); i++) {
- ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo();
- int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i));
-
- float score;
- if (shortcutType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE) {
- // Incoming results are ordered. Create a score based on index in the original list.
- score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f);
- } else {
- // Create a score based on the rank of the shortcut.
- int rankIndex = scoreList.indexOf(shortcutInfo.getRank());
- score = Math.max(1.0f - (0.01f * rankIndex), 0.0f);
- }
-
- Bundle extras = new Bundle();
- extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId());
-
- ChooserTarget chooserTarget = new ChooserTarget(
- shortcutInfo.getLabel(),
- null, // Icon will be loaded later if this target is selected to be shown.
- score, matchingShortcuts.get(i).getTargetComponent().clone(), extras);
-
- chooserTargetList.add(chooserTarget);
- if (mDirectShareAppTargetCache != null && allAppTargets != null) {
- mDirectShareAppTargetCache.put(chooserTarget,
- allAppTargets.get(indexInAllShortcuts));
- }
- if (mDirectShareShortcutInfoCache != null) {
- mDirectShareShortcutInfoCache.put(chooserTarget, shortcutInfo);
- }
- }
- // Sort ChooserTargets by score in descending order
- Comparator<ChooserTarget> byScore =
- (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore());
- Collections.sort(chooserTargetList, byScore);
- return chooserTargetList;
- }
-
- private void logDirectShareTargetReceived(int logCategory) {
- final int apiLatency = (int) (System.currentTimeMillis() - mQueriedSharingShortcutsTimeMs);
- getMetricsLogger().write(new LogMaker(logCategory).setSubtype(apiLatency));
+ getChooserActivityLogger().logDirectShareTargetReceived(
+ MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER,
+ (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime));
}
void updateModelAndChooserCounts(TargetInfo info) {
- if (info != null && info instanceof MultiDisplayResolveInfo) {
+ if (info != null && info.isMultiDisplayResolveInfo()) {
info = ((MultiDisplayResolveInfo) info).getSelectedTarget();
}
if (info != null) {
@@ -2200,31 +1308,35 @@
Log.d(TAG, "Action to be updated is " + targetIntent.getAction());
}
} else if (DEBUG) {
- Log.d(TAG, "Can not log Chooser Counts of null ResovleInfo");
+ Log.d(TAG, "Can not log Chooser Counts of null ResolveInfo");
}
}
mIsSuccessfullySelected = true;
}
private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) {
- AppPredictor directShareAppPredictor = getAppPredictorForDirectShareIfEnabled(
+ // Send DS target impression info to AppPredictor, only when user chooses app share.
+ if (targetInfo.isChooserTargetInfo()) {
+ return;
+ }
+
+ AppPredictor directShareAppPredictor = getAppPredictor(
mChooserMultiProfilePagerAdapter.getCurrentUserHandle());
if (directShareAppPredictor == null) {
return;
}
- // Send DS target impression info to AppPredictor, only when user chooses app share.
- if (targetInfo instanceof ChooserTargetInfo) {
- return;
- }
- List<ChooserTargetInfo> surfacedTargetInfo = adapter.getSurfacedTargetInfo();
+ List<TargetInfo> surfacedTargetInfo = adapter.getSurfacedTargetInfo();
List<AppTargetId> targetIds = new ArrayList<>();
- for (ChooserTargetInfo chooserTargetInfo : surfacedTargetInfo) {
- ChooserTarget chooserTarget = chooserTargetInfo.getChooserTarget();
- ComponentName componentName = chooserTarget.getComponentName();
- if (mDirectShareShortcutInfoCache.containsKey(chooserTarget)) {
- String shortcutId = mDirectShareShortcutInfoCache.get(chooserTarget).getId();
+ for (TargetInfo chooserTargetInfo : surfacedTargetInfo) {
+ ShortcutInfo shortcutInfo = chooserTargetInfo.getDirectShareShortcutInfo();
+ if (shortcutInfo != null) {
+ ComponentName componentName =
+ chooserTargetInfo.getChooserTargetComponentName();
targetIds.add(new AppTargetId(
- String.format("%s/%s/%s", shortcutId, componentName.flattenToString(),
+ String.format(
+ "%s/%s/%s",
+ shortcutInfo.getId(),
+ componentName.flattenToString(),
SHORTCUT_TARGET)));
}
}
@@ -2232,21 +1344,18 @@
}
private void sendClickToAppPredictor(TargetInfo targetInfo) {
- AppPredictor directShareAppPredictor = getAppPredictorForDirectShareIfEnabled(
+ if (!targetInfo.isChooserTargetInfo()) {
+ return;
+ }
+
+ AppPredictor directShareAppPredictor = getAppPredictor(
mChooserMultiProfilePagerAdapter.getCurrentUserHandle());
if (directShareAppPredictor == null) {
return;
}
- if (!(targetInfo instanceof ChooserTargetInfo)) {
- return;
- }
- ChooserTarget chooserTarget = ((ChooserTargetInfo) targetInfo).getChooserTarget();
- AppTarget appTarget = null;
- if (mDirectShareAppTargetCache != null) {
- appTarget = mDirectShareAppTargetCache.get(chooserTarget);
- }
- // This is a direct share click that was provided by the APS
+ AppTarget appTarget = targetInfo.getDirectShareAppTarget();
if (appTarget != null) {
+ // This is a direct share click that was provided by the APS
directShareAppPredictor.notifyAppTargetEvent(
new AppTargetEvent.Builder(appTarget, AppTargetEvent.ACTION_LAUNCH)
.setLaunchLocation(LAUNCH_LOCATION_DIRECT_SHARE)
@@ -2255,70 +1364,9 @@
}
@Nullable
- private AppPredictor createAppPredictor(UserHandle userHandle) {
- if (!mIsAppPredictorComponentAvailable) {
- return null;
- }
-
- if (getPersonalProfileUserHandle().equals(userHandle)) {
- if (mPersonalAppPredictor != null) {
- return mPersonalAppPredictor;
- }
- } else {
- if (mWorkAppPredictor != null) {
- return mWorkAppPredictor;
- }
- }
-
- // TODO(b/148230574): Currently AppPredictor fetches only the same-profile app targets.
- // Make AppPredictor work cross-profile.
- Context contextAsUser = createContextAsUser(userHandle, 0 /* flags */);
- final IntentFilter filter = getTargetIntentFilter();
- Bundle extras = new Bundle();
- extras.putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, filter);
- populateTextContent(extras);
- AppPredictionContext appPredictionContext = new AppPredictionContext.Builder(contextAsUser)
- .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE)
- .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT)
- .setExtras(extras)
- .build();
- AppPredictionManager appPredictionManager =
- contextAsUser
- .getSystemService(AppPredictionManager.class);
- AppPredictor appPredictionSession = appPredictionManager.createAppPredictionSession(
- appPredictionContext);
- if (getPersonalProfileUserHandle().equals(userHandle)) {
- mPersonalAppPredictor = appPredictionSession;
- } else {
- mWorkAppPredictor = appPredictionSession;
- }
- return appPredictionSession;
- }
-
- private void populateTextContent(Bundle extras) {
- final Intent intent = getTargetIntent();
- String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT);
- extras.putString(SHARED_TEXT_KEY, sharedText);
- }
-
- /**
- * This will return an app predictor if it is enabled for direct share sorting
- * and if one exists. Otherwise, it returns null.
- * @param userHandle
- */
- @Nullable
- private AppPredictor getAppPredictorForDirectShareIfEnabled(UserHandle userHandle) {
- return ChooserFlags.USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS
- && !ActivityManager.isLowRamDeviceStatic() ? createAppPredictor(userHandle) : null;
- }
-
- /**
- * This will return an app predictor if it is enabled for share activity sorting
- * and if one exists. Otherwise, it returns null.
- */
- @Nullable
- private AppPredictor getAppPredictorForShareActivitiesIfEnabled(UserHandle userHandle) {
- return USE_PREDICTION_MANAGER_FOR_SHARE_ACTIVITIES ? createAppPredictor(userHandle) : null;
+ private AppPredictor getAppPredictor(UserHandle userHandle) {
+ ProfileRecord record = getProfileRecord(userHandle);
+ return (record == null) ? null : record.appPredictor;
}
void onRefinementResult(TargetInfo selectedTarget, Intent matchingIntent) {
@@ -2377,16 +1425,9 @@
}
}
- protected MetricsLogger getMetricsLogger() {
- if (mMetricsLogger == null) {
- mMetricsLogger = new MetricsLogger();
- }
- return mMetricsLogger;
- }
-
protected ChooserActivityLogger getChooserActivityLogger() {
if (mChooserActivityLogger == null) {
- mChooserActivityLogger = new ChooserActivityLoggerImpl();
+ mChooserActivityLogger = new ChooserActivityLogger();
}
return mChooserActivityLogger;
}
@@ -2405,56 +1446,139 @@
@Override
boolean isComponentFiltered(ComponentName name) {
- if (mFilteredComponentNames == null) {
- return false;
- }
- for (ComponentName filteredComponentName : mFilteredComponentNames) {
- if (name.equals(filteredComponentName)) {
- return true;
- }
- }
- return false;
+ return mChooserRequest.getFilteredComponentNames().contains(name);
}
@Override
public boolean isComponentPinned(ComponentName name) {
return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false);
}
-
- @Override
- public boolean isFixedAtTop(ComponentName name) {
- return name != null && name.equals(getNearbySharingComponent())
- && shouldNearbyShareBeFirstInRankedRow();
- }
}
@VisibleForTesting
- public ChooserGridAdapter createChooserGridAdapter(Context context,
- List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList,
- boolean filterLastUsed, UserHandle userHandle) {
- ChooserListAdapter chooserListAdapter = createChooserListAdapter(context, payloadIntents,
- initialIntents, rList, filterLastUsed,
- createListController(userHandle));
- AppPredictor.Callback appPredictorCallback = createAppPredictorCallback(chooserListAdapter);
- AppPredictor appPredictor = setupAppPredictorForUser(userHandle, appPredictorCallback);
- chooserListAdapter.setAppPredictor(appPredictor);
- chooserListAdapter.setAppPredictorCallback(appPredictorCallback);
- return new ChooserGridAdapter(chooserListAdapter);
+ public ChooserGridAdapter createChooserGridAdapter(
+ Context context,
+ List<Intent> payloadIntents,
+ Intent[] initialIntents,
+ List<ResolveInfo> rList,
+ boolean filterLastUsed,
+ UserHandle userHandle) {
+ ChooserListAdapter chooserListAdapter = createChooserListAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ createListController(userHandle),
+ userHandle,
+ getTargetIntent(),
+ mChooserRequest,
+ mMaxTargetsPerRow);
+
+ return new ChooserGridAdapter(
+ context,
+ new ChooserGridAdapter.ChooserActivityDelegate() {
+ @Override
+ public boolean shouldShowTabs() {
+ return ChooserActivity.this.shouldShowTabs();
+ }
+
+ @Override
+ public View buildContentPreview(ViewGroup parent) {
+ return createContentPreviewView(parent, mPreviewCoordinator);
+ }
+
+ @Override
+ public void onTargetSelected(int itemIndex) {
+ startSelected(itemIndex, false, true);
+ }
+
+ @Override
+ public void onTargetLongPressed(int selectedPosition) {
+ final TargetInfo longPressedTargetInfo =
+ mChooserMultiProfilePagerAdapter
+ .getActiveListAdapter()
+ .targetInfoForPosition(
+ selectedPosition, /* filtered= */ true);
+ // ItemViewHolder contents should always be "display resolve info"
+ // targets, but check just to make sure.
+ if (longPressedTargetInfo.isDisplayResolveInfo()) {
+ showTargetDetails(longPressedTargetInfo);
+ }
+ }
+
+ @Override
+ public void updateProfileViewButton(View newButtonFromProfileRow) {
+ mProfileView = newButtonFromProfileRow;
+ mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick);
+ ChooserActivity.this.updateProfileViewButton();
+ }
+
+ @Override
+ public int getValidTargetCount() {
+ return mChooserMultiProfilePagerAdapter
+ .getActiveListAdapter()
+ .getSelectableServiceTargetCount();
+ }
+
+ @Override
+ public void updateDirectShareExpansion(DirectShareViewHolder directShareGroup) {
+ RecyclerView activeAdapterView =
+ mChooserMultiProfilePagerAdapter.getActiveAdapterView();
+ if (mResolverDrawerLayout.isCollapsed()) {
+ directShareGroup.collapse(activeAdapterView);
+ } else {
+ directShareGroup.expand(activeAdapterView);
+ }
+ }
+
+ @Override
+ public void handleScrollToExpandDirectShare(
+ DirectShareViewHolder directShareGroup, int y, int oldy) {
+ directShareGroup.handleScroll(
+ mChooserMultiProfilePagerAdapter.getActiveAdapterView(),
+ y,
+ oldy,
+ mMaxTargetsPerRow);
+ }
+ },
+ chooserListAdapter,
+ shouldShowContentPreview(),
+ mMaxTargetsPerRow,
+ getNumSheetExpansions());
}
@VisibleForTesting
- public ChooserListAdapter createChooserListAdapter(Context context,
- List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList,
- boolean filterLastUsed, ResolverListController resolverListController) {
- return new ChooserListAdapter(context, payloadIntents, initialIntents, rList,
- filterLastUsed, resolverListController, this,
- this, context.getPackageManager(),
- getChooserActivityLogger());
+ public ChooserListAdapter createChooserListAdapter(
+ Context context,
+ List<Intent> payloadIntents,
+ Intent[] initialIntents,
+ List<ResolveInfo> rList,
+ boolean filterLastUsed,
+ ResolverListController resolverListController,
+ UserHandle userHandle,
+ Intent targetIntent,
+ ChooserRequestParameters chooserRequest,
+ int maxTargetsPerRow) {
+ return new ChooserListAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ this,
+ context.getPackageManager(),
+ getChooserActivityLogger(),
+ chooserRequest,
+ maxTargetsPerRow);
}
@VisibleForTesting
protected ResolverListController createListController(UserHandle userHandle) {
- AppPredictor appPredictor = getAppPredictorForShareActivitiesIfEnabled(userHandle);
+ AppPredictor appPredictor = getAppPredictor(userHandle);
AbstractResolverComparator resolverComparator;
if (appPredictor != null) {
resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(),
@@ -2484,28 +1608,11 @@
try {
return getContentResolver().loadThumbnail(uri, size, null);
} catch (IOException | NullPointerException | SecurityException ex) {
- logContentPreviewWarning(uri);
+ getChooserActivityLogger().logContentPreviewWarning(uri);
}
return null;
}
- static final class PlaceHolderTargetInfo extends NotSelectableTargetInfo {
- public Drawable getDisplayIcon(Context context) {
- AnimatedVectorDrawable avd = (AnimatedVectorDrawable)
- context.getDrawable(R.drawable.chooser_direct_share_icon_placeholder);
- avd.start(); // Start animation after generation
- return avd;
- }
- }
-
- protected static final class EmptyTargetInfo extends NotSelectableTargetInfo {
- public EmptyTargetInfo() {}
-
- public Drawable getDisplayIcon(Context context) {
- return null;
- }
- }
-
private void handleScroll(View view, int x, int y, int oldx, int oldy) {
if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) {
mChooserMultiProfilePagerAdapter.getCurrentRootAdapter().handleScroll(view, y, oldy);
@@ -2532,8 +1639,8 @@
}
final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight();
- boolean isLayoutUpdated = gridAdapter.consumeLayoutRequest()
- || gridAdapter.calculateChooserTargetWidth(availableWidth)
+ boolean isLayoutUpdated =
+ gridAdapter.calculateChooserTargetWidth(availableWidth)
|| recyclerView.getAdapter() == null
|| availableWidth != mCurrAvailableWidth;
@@ -2639,7 +1746,7 @@
boolean isExpandable = getResources().getConfiguration().orientation
== Configuration.ORIENTATION_PORTRAIT && !isInMultiWindowMode();
- if (directShareHeight != 0 && isSendAction(getTargetIntent())
+ if (directShareHeight != 0 && shouldShowContentPreview()
&& isExpandable) {
// make sure to leave room for direct share 4->8 expansion
int requiredExpansionHeight =
@@ -2688,15 +1795,7 @@
private ViewGroup getActiveEmptyStateView() {
int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage();
- return mChooserMultiProfilePagerAdapter.getItem(currentPage).getEmptyStateView();
- }
-
- static class BaseChooserTargetComparator implements Comparator<ChooserTarget> {
- @Override
- public int compare(ChooserTarget lhs, ChooserTarget rhs) {
- // Descending order
- return (int) Math.signum(rhs.getScore() - lhs.getScore());
- }
+ return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage);
}
@Override // ResolverListCommunicator
@@ -2705,29 +1804,6 @@
super.onHandlePackagesChanged(listAdapter);
}
- @Override // SelectableTargetInfoCommunicator
- public ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo info) {
- return mChooserMultiProfilePagerAdapter.getActiveListAdapter().makePresentationGetter(info);
- }
-
- @Override // SelectableTargetInfoCommunicator
- public Intent getReferrerFillInIntent() {
- return mReferrerFillInIntent;
- }
-
- @Override // ChooserListCommunicator
- public int getMaxRankedTargets() {
- return mMaxTargetsPerRow;
- }
-
- @Override // ChooserListCommunicator
- public void sendListViewUpdateMessage(UserHandle userHandle) {
- Message msg = Message.obtain();
- msg.what = ChooserHandler.LIST_VIEW_UPDATE_MESSAGE;
- msg.obj = userHandle;
- mChooserHandler.sendMessageDelayed(msg, mListViewUpdateDelayMs);
- }
-
@Override
public void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) {
setupScrollListener();
@@ -2742,8 +1818,7 @@
.setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage());
}
- if (chooserListAdapter.mDisplayList == null
- || chooserListAdapter.mDisplayList.isEmpty()) {
+ if (chooserListAdapter.getDisplayResolveInfoCount() == 0) {
chooserListAdapter.notifyDataSetChanged();
} else {
chooserListAdapter.updateAlphabeticalList();
@@ -2757,41 +1832,45 @@
}
private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) {
- // don't support direct share on low ram devices
- if (ActivityManager.isLowRamDeviceStatic()) {
+ UserHandle userHandle = chooserListAdapter.getUserHandle();
+ ProfileRecord record = getProfileRecord(userHandle);
+ if (record == null) {
return;
}
-
- // no need to query direct share for work profile when its locked or disabled
- if (!shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) {
+ if (record.shortcutLoader == null) {
return;
}
+ record.loadingStartTime = SystemClock.elapsedRealtime();
+ record.shortcutLoader.queryShortcuts(chooserListAdapter.getDisplayResolveInfos());
+ }
- if (ChooserFlags.USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS) {
- if (DEBUG) {
- Log.d(TAG, "querying direct share targets from ShortcutManager");
+ @MainThread
+ private void onShortcutsLoaded(
+ UserHandle userHandle, ShortcutLoader.Result shortcutsResult) {
+ if (DEBUG) {
+ Log.d(TAG, "onShortcutsLoaded for user: " + userHandle);
+ }
+ mDirectShareShortcutInfoCache.putAll(shortcutsResult.directShareShortcutInfoCache);
+ mDirectShareAppTargetCache.putAll(shortcutsResult.directShareAppTargetCache);
+ ChooserListAdapter adapter =
+ mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle);
+ if (adapter != null) {
+ for (ShortcutLoader.ShortcutResultInfo resultInfo : shortcutsResult.shortcutsByApp) {
+ adapter.addServiceResults(
+ resultInfo.appTarget,
+ resultInfo.shortcuts,
+ shortcutsResult.isFromAppPredictor
+ ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE
+ : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER,
+ mDirectShareShortcutInfoCache,
+ mDirectShareAppTargetCache);
}
-
- queryDirectShareTargets(chooserListAdapter, false);
+ adapter.completeServiceTargetLoading();
}
- }
- @VisibleForTesting
- protected boolean isUserRunning(UserHandle userHandle) {
- UserManager userManager = getSystemService(UserManager.class);
- return userManager.isUserRunning(userHandle);
- }
-
- @VisibleForTesting
- protected boolean isUserUnlocked(UserHandle userHandle) {
- UserManager userManager = getSystemService(UserManager.class);
- return userManager.isUserUnlocked(userHandle);
- }
-
- @VisibleForTesting
- protected boolean isQuietModeEnabled(UserHandle userHandle) {
- UserManager userManager = getSystemService(UserManager.class);
- return userManager.isQuietModeEnabled(userHandle);
+ logDirectShareTargetReceived(userHandle);
+ sendVoiceChoicesIfNeeded();
+ getChooserActivityLogger().logSharesheetDirectLoadComplete();
}
private void setupScrollListener() {
@@ -2855,24 +1934,6 @@
});
}
- @Override // ChooserListCommunicator
- public boolean isSendAction(Intent targetIntent) {
- if (targetIntent == null) {
- return false;
- }
-
- String action = targetIntent.getAction();
- if (action == null) {
- return false;
- }
-
- if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) {
- return true;
- }
-
- return false;
- }
-
/**
* The sticky content preview is shown only when we have a tabbed view. It's shown above
* the tabs so it is not part of the scrollable list. If we are not in tabbed view,
@@ -2887,7 +1948,14 @@
return shouldShowTabs()
&& mMultiProfilePagerAdapter.getListAdapterForUserHandle(
UserHandle.of(UserHandle.myUserId())).getCount() > 0
- && isSendAction(getTargetIntent());
+ && shouldShowContentPreview();
+ }
+
+ /**
+ * @return true if we want to show the content preview area
+ */
+ protected boolean shouldShowContentPreview() {
+ return (mChooserRequest != null) && mChooserRequest.isSendActionTarget();
}
private void updateStickyContentPreview() {
@@ -2898,7 +1966,8 @@
// then always preload it to avoid subsequent resizing of the share sheet.
ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container);
if (contentPreviewContainer.getChildCount() == 0) {
- ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer);
+ ViewGroup contentPreviewView =
+ createContentPreviewView(contentPreviewContainer, mPreviewCoordinator);
contentPreviewContainer.addView(contentPreviewView);
}
}
@@ -2930,21 +1999,16 @@
contentPreviewContainer.setVisibility(View.GONE);
}
- private void logActionShareWithPreview() {
- Intent targetIntent = getTargetIntent();
- int previewType = findPreferredContentPreview(targetIntent, getContentResolver());
- getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW)
- .setSubtype(previewType));
- }
-
private void startFinishAnimation() {
View rootView = findRootView();
- rootView.startAnimation(new FinishAnimation(this, rootView));
+ if (rootView != null) {
+ rootView.startAnimation(new FinishAnimation(this, rootView));
+ }
}
private boolean maybeCancelFinishAnimation() {
View rootView = findRootView();
- Animation animation = rootView.getAnimation();
+ Animation animation = (rootView == null) ? null : rootView.getAnimation();
if (animation instanceof FinishAnimation) {
boolean hasEnded = animation.hasEnded();
animation.cancel();
@@ -2961,69 +2025,6 @@
return mContentView;
}
- abstract static class ViewHolderBase extends RecyclerView.ViewHolder {
- private int mViewType;
-
- ViewHolderBase(View itemView, int viewType) {
- super(itemView);
- this.mViewType = viewType;
- }
-
- int getViewType() {
- return mViewType;
- }
- }
-
- /**
- * Used to bind types of individual item including
- * {@link ChooserGridAdapter#VIEW_TYPE_NORMAL},
- * {@link ChooserGridAdapter#VIEW_TYPE_CONTENT_PREVIEW},
- * {@link ChooserGridAdapter#VIEW_TYPE_PROFILE},
- * and {@link ChooserGridAdapter#VIEW_TYPE_AZ_LABEL}.
- */
- final class ItemViewHolder extends ViewHolderBase {
- ResolverListAdapter.ViewHolder mWrappedViewHolder;
- int mListPosition = ChooserListAdapter.NO_POSITION;
-
- ItemViewHolder(View itemView, boolean isClickable, int viewType) {
- super(itemView, viewType);
- mWrappedViewHolder = new ResolverListAdapter.ViewHolder(itemView);
- if (isClickable) {
- itemView.setOnClickListener(v -> startSelected(mListPosition,
- false/* always */, true/* filterd */));
-
- itemView.setOnLongClickListener(v -> {
- final TargetInfo ti = mChooserMultiProfilePagerAdapter.getActiveListAdapter()
- .targetInfoForPosition(mListPosition, /* filtered */ true);
-
- // This should always be the case for ItemViewHolder, check for validity
- if (ti instanceof DisplayResolveInfo && shouldShowTargetDetails(ti)) {
- showTargetDetails((DisplayResolveInfo) ti);
- }
- return true;
- });
- }
- }
- }
-
- private boolean shouldShowTargetDetails(TargetInfo ti) {
- ComponentName nearbyShare = getNearbySharingComponent();
- // Suppress target details for nearby share to hide pin/unpin action
- boolean isNearbyShare = nearbyShare != null && nearbyShare.equals(
- ti.getResolvedComponentName()) && shouldNearbyShareBeFirstInRankedRow();
- return ti instanceof SelectableTargetInfo
- || (ti instanceof DisplayResolveInfo && !isNearbyShare);
- }
-
- /**
- * Add a footer to the list, to support scrolling behavior below the navbar.
- */
- static final class FooterViewHolder extends ViewHolderBase {
- FooterViewHolder(View itemView, int viewType) {
- super(itemView, viewType);
- }
- }
-
/**
* Intentionally override the {@link ResolverActivity} implementation as we only need that
* implementation for the intent resolver case.
@@ -3100,763 +2101,6 @@
}
}
- /**
- * Adapter for all types of items and targets in ShareSheet.
- * Note that ranked sections like Direct Share - while appearing grid-like - are handled on the
- * row level by this adapter but not on the item level. Individual targets within the row are
- * handled by {@link ChooserListAdapter}
- */
- @VisibleForTesting
- public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
- private ChooserListAdapter mChooserListAdapter;
- private final LayoutInflater mLayoutInflater;
-
- private DirectShareViewHolder mDirectShareViewHolder;
- private int mChooserTargetWidth = 0;
- private boolean mShowAzLabelIfPoss;
- private boolean mLayoutRequested = false;
-
- private int mFooterHeight = 0;
-
- private static final int VIEW_TYPE_DIRECT_SHARE = 0;
- private static final int VIEW_TYPE_NORMAL = 1;
- private static final int VIEW_TYPE_CONTENT_PREVIEW = 2;
- private static final int VIEW_TYPE_PROFILE = 3;
- private static final int VIEW_TYPE_AZ_LABEL = 4;
- private static final int VIEW_TYPE_CALLER_AND_RANK = 5;
- private static final int VIEW_TYPE_FOOTER = 6;
-
- private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20;
-
- ChooserGridAdapter(ChooserListAdapter wrappedAdapter) {
- super();
- mChooserListAdapter = wrappedAdapter;
- mLayoutInflater = LayoutInflater.from(ChooserActivity.this);
-
- mShowAzLabelIfPoss = getNumSheetExpansions() < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL;
-
- wrappedAdapter.registerDataSetObserver(new DataSetObserver() {
- @Override
- public void onChanged() {
- super.onChanged();
- notifyDataSetChanged();
- }
-
- @Override
- public void onInvalidated() {
- super.onInvalidated();
- notifyDataSetChanged();
- }
- });
- }
-
- public void setFooterHeight(int height) {
- mFooterHeight = height;
- }
-
- /**
- * Calculate the chooser target width to maximize space per item
- *
- * @param width The new row width to use for recalculation
- * @return true if the view width has changed
- */
- public boolean calculateChooserTargetWidth(int width) {
- if (width == 0) {
- return false;
- }
-
- // Limit width to the maximum width of the chooser activity
- int maxWidth = getResources().getDimensionPixelSize(R.dimen.chooser_width);
- width = Math.min(maxWidth, width);
-
- int newWidth = width / mMaxTargetsPerRow;
- if (newWidth != mChooserTargetWidth) {
- mChooserTargetWidth = newWidth;
- return true;
- }
-
- return false;
- }
-
- /**
- * Hides the list item content preview.
- * <p>Not to be confused with the sticky content preview which is above the
- * personal and work tabs.
- */
- public void hideContentPreview() {
- mLayoutRequested = true;
- notifyDataSetChanged();
- }
-
- public boolean consumeLayoutRequest() {
- boolean oldValue = mLayoutRequested;
- mLayoutRequested = false;
- return oldValue;
- }
-
- public int getRowCount() {
- return (int) (
- getSystemRowCount()
- + getProfileRowCount()
- + getServiceTargetRowCount()
- + getCallerAndRankedTargetRowCount()
- + getAzLabelRowCount()
- + Math.ceil(
- (float) mChooserListAdapter.getAlphaTargetCount()
- / mMaxTargetsPerRow)
- );
- }
-
- /**
- * Whether the "system" row of targets is displayed.
- * This area includes the content preview (if present) and action row.
- */
- public int getSystemRowCount() {
- // For the tabbed case we show the sticky content preview above the tabs,
- // please refer to shouldShowStickyContentPreview
- if (shouldShowTabs()) {
- return 0;
- }
-
- if (!isSendAction(getTargetIntent())) {
- return 0;
- }
-
- if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) {
- return 0;
- }
-
- return 1;
- }
-
- public int getProfileRowCount() {
- if (shouldShowTabs()) {
- return 0;
- }
- return mChooserListAdapter.getOtherProfile() == null ? 0 : 1;
- }
-
- public int getFooterRowCount() {
- return 1;
- }
-
- public int getCallerAndRankedTargetRowCount() {
- return (int) Math.ceil(
- ((float) mChooserListAdapter.getCallerTargetCount()
- + mChooserListAdapter.getRankedTargetCount()) / mMaxTargetsPerRow);
- }
-
- // There can be at most one row in the listview, that is internally
- // a ViewGroup with 2 rows
- public int getServiceTargetRowCount() {
- if (isSendAction(getTargetIntent())
- && !ActivityManager.isLowRamDeviceStatic()) {
- return 1;
- }
- return 0;
- }
-
- public int getAzLabelRowCount() {
- // Only show a label if the a-z list is showing
- return (mShowAzLabelIfPoss && mChooserListAdapter.getAlphaTargetCount() > 0) ? 1 : 0;
- }
-
- @Override
- public int getItemCount() {
- return (int) (
- getSystemRowCount()
- + getProfileRowCount()
- + getServiceTargetRowCount()
- + getCallerAndRankedTargetRowCount()
- + getAzLabelRowCount()
- + mChooserListAdapter.getAlphaTargetCount()
- + getFooterRowCount()
- );
- }
-
- @Override
- public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
- switch (viewType) {
- case VIEW_TYPE_CONTENT_PREVIEW:
- return new ItemViewHolder(createContentPreviewView(parent), false, viewType);
- case VIEW_TYPE_PROFILE:
- return new ItemViewHolder(createProfileView(parent), false, viewType);
- case VIEW_TYPE_AZ_LABEL:
- return new ItemViewHolder(createAzLabelView(parent), false, viewType);
- case VIEW_TYPE_NORMAL:
- return new ItemViewHolder(
- mChooserListAdapter.createView(parent), true, viewType);
- case VIEW_TYPE_DIRECT_SHARE:
- case VIEW_TYPE_CALLER_AND_RANK:
- return createItemGroupViewHolder(viewType, parent);
- case VIEW_TYPE_FOOTER:
- Space sp = new Space(parent.getContext());
- sp.setLayoutParams(new RecyclerView.LayoutParams(
- LayoutParams.MATCH_PARENT, mFooterHeight));
- return new FooterViewHolder(sp, viewType);
- default:
- // Since we catch all possible viewTypes above, no chance this is being called.
- return null;
- }
- }
-
- @Override
- public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
- int viewType = ((ViewHolderBase) holder).getViewType();
- switch (viewType) {
- case VIEW_TYPE_DIRECT_SHARE:
- case VIEW_TYPE_CALLER_AND_RANK:
- bindItemGroupViewHolder(position, (ItemGroupViewHolder) holder);
- break;
- case VIEW_TYPE_NORMAL:
- bindItemViewHolder(position, (ItemViewHolder) holder);
- break;
- default:
- }
- }
-
- @Override
- public int getItemViewType(int position) {
- int count;
-
- int countSum = (count = getSystemRowCount());
- if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW;
-
- countSum += (count = getProfileRowCount());
- if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE;
-
- countSum += (count = getServiceTargetRowCount());
- if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE;
-
- countSum += (count = getCallerAndRankedTargetRowCount());
- if (count > 0 && position < countSum) return VIEW_TYPE_CALLER_AND_RANK;
-
- countSum += (count = getAzLabelRowCount());
- if (count > 0 && position < countSum) return VIEW_TYPE_AZ_LABEL;
-
- if (position == getItemCount() - 1) return VIEW_TYPE_FOOTER;
-
- return VIEW_TYPE_NORMAL;
- }
-
- public int getTargetType(int position) {
- return mChooserListAdapter.getPositionTargetType(getListPosition(position));
- }
-
- private View createProfileView(ViewGroup parent) {
- View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false);
- mProfileView = profileRow.findViewById(com.android.internal.R.id.profile_button);
- mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick);
- updateProfileViewButton();
- return profileRow;
- }
-
- private View createAzLabelView(ViewGroup parent) {
- return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false);
- }
-
- private ItemGroupViewHolder loadViewsIntoGroup(ItemGroupViewHolder holder) {
- final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
- final int exactSpec = MeasureSpec.makeMeasureSpec(mChooserTargetWidth,
- MeasureSpec.EXACTLY);
- int columnCount = holder.getColumnCount();
-
- final boolean isDirectShare = holder instanceof DirectShareViewHolder;
-
- for (int i = 0; i < columnCount; i++) {
- final View v = mChooserListAdapter.createView(holder.getRowByIndex(i));
- final int column = i;
- v.setOnClickListener(new OnClickListener() {
- @Override
- public void onClick(View v) {
- startSelected(holder.getItemIndex(column), false, true);
- }
- });
-
- // Show menu for both direct share and app share targets after long click.
- v.setOnLongClickListener(v1 -> {
- TargetInfo ti = mChooserListAdapter.targetInfoForPosition(
- holder.getItemIndex(column), true);
- if (shouldShowTargetDetails(ti)) {
- showTargetDetails(ti);
- }
- return true;
- });
-
- holder.addView(i, v);
-
- // Force Direct Share to be 2 lines and auto-wrap to second line via hoz scroll =
- // false. TextView#setHorizontallyScrolling must be reset after #setLines. Must be
- // done before measuring.
- if (isDirectShare) {
- final ViewHolder vh = (ViewHolder) v.getTag();
- vh.text.setLines(2);
- vh.text.setHorizontallyScrolling(false);
- vh.text2.setVisibility(View.GONE);
- }
-
- // Force height to be a given so we don't have visual disruption during scaling.
- v.measure(exactSpec, spec);
- setViewBounds(v, v.getMeasuredWidth(), v.getMeasuredHeight());
- }
-
- final ViewGroup viewGroup = holder.getViewGroup();
-
- // Pre-measure and fix height so we can scale later.
- holder.measure();
- setViewBounds(viewGroup, LayoutParams.MATCH_PARENT, holder.getMeasuredRowHeight());
-
- if (isDirectShare) {
- DirectShareViewHolder dsvh = (DirectShareViewHolder) holder;
- setViewBounds(dsvh.getRow(0), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight());
- setViewBounds(dsvh.getRow(1), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight());
- }
-
- viewGroup.setTag(holder);
- return holder;
- }
-
- private void setViewBounds(View view, int widthPx, int heightPx) {
- LayoutParams lp = view.getLayoutParams();
- if (lp == null) {
- lp = new LayoutParams(widthPx, heightPx);
- view.setLayoutParams(lp);
- } else {
- lp.height = heightPx;
- lp.width = widthPx;
- }
- }
-
- ItemGroupViewHolder createItemGroupViewHolder(int viewType, ViewGroup parent) {
- if (viewType == VIEW_TYPE_DIRECT_SHARE) {
- ViewGroup parentGroup = (ViewGroup) mLayoutInflater.inflate(
- R.layout.chooser_row_direct_share, parent, false);
- ViewGroup row1 = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row,
- parentGroup, false);
- ViewGroup row2 = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row,
- parentGroup, false);
- parentGroup.addView(row1);
- parentGroup.addView(row2);
-
- mDirectShareViewHolder = new DirectShareViewHolder(parentGroup,
- Lists.newArrayList(row1, row2), mMaxTargetsPerRow, viewType,
- mChooserMultiProfilePagerAdapter::getActiveListAdapter);
- loadViewsIntoGroup(mDirectShareViewHolder);
-
- return mDirectShareViewHolder;
- } else {
- ViewGroup row = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, parent,
- false);
- ItemGroupViewHolder holder =
- new SingleRowViewHolder(row, mMaxTargetsPerRow, viewType);
- loadViewsIntoGroup(holder);
-
- return holder;
- }
- }
-
- /**
- * Need to merge CALLER + ranked STANDARD into a single row and prevent a separator from
- * showing on top of the AZ list if the AZ label is visible. All other types are placed into
- * their own row as determined by their target type, and dividers are added in the list to
- * separate each type.
- */
- int getRowType(int rowPosition) {
- // Merge caller and ranked standard into a single row
- int positionType = mChooserListAdapter.getPositionTargetType(rowPosition);
- if (positionType == ChooserListAdapter.TARGET_CALLER) {
- return ChooserListAdapter.TARGET_STANDARD;
- }
-
- // If an the A-Z label is shown, prevent a separator from appearing by making the A-Z
- // row type the same as the suggestion row type
- if (getAzLabelRowCount() > 0 && positionType == ChooserListAdapter.TARGET_STANDARD_AZ) {
- return ChooserListAdapter.TARGET_STANDARD;
- }
-
- return positionType;
- }
-
- void bindItemViewHolder(int position, ItemViewHolder holder) {
- View v = holder.itemView;
- int listPosition = getListPosition(position);
- holder.mListPosition = listPosition;
- mChooserListAdapter.bindView(listPosition, v);
- }
-
- void bindItemGroupViewHolder(int position, ItemGroupViewHolder holder) {
- final ViewGroup viewGroup = (ViewGroup) holder.itemView;
- int start = getListPosition(position);
- int startType = getRowType(start);
-
- int columnCount = holder.getColumnCount();
- int end = start + columnCount - 1;
- while (getRowType(end) != startType && end >= start) {
- end--;
- }
-
- if (end == start && mChooserListAdapter.getItem(start) instanceof EmptyTargetInfo) {
- final TextView textView = viewGroup.findViewById(com.android.internal.R.id.chooser_row_text_option);
-
- if (textView.getVisibility() != View.VISIBLE) {
- textView.setAlpha(0.0f);
- textView.setVisibility(View.VISIBLE);
- textView.setText(R.string.chooser_no_direct_share_targets);
-
- ValueAnimator fadeAnim = ObjectAnimator.ofFloat(textView, "alpha", 0.0f, 1.0f);
- fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
-
- float translationInPx = getResources().getDimensionPixelSize(
- R.dimen.chooser_row_text_option_translate);
- textView.setTranslationY(translationInPx);
- ValueAnimator translateAnim = ObjectAnimator.ofFloat(textView, "translationY",
- 0.0f);
- translateAnim.setInterpolator(new DecelerateInterpolator(1.0f));
-
- AnimatorSet animSet = new AnimatorSet();
- animSet.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS);
- animSet.setStartDelay(NO_DIRECT_SHARE_ANIM_IN_MILLIS);
- animSet.playTogether(fadeAnim, translateAnim);
- animSet.start();
- }
- }
-
- for (int i = 0; i < columnCount; i++) {
- final View v = holder.getView(i);
-
- if (start + i <= end) {
- holder.setViewVisibility(i, View.VISIBLE);
- holder.setItemIndex(i, start + i);
- mChooserListAdapter.bindView(holder.getItemIndex(i), v);
- } else {
- holder.setViewVisibility(i, View.INVISIBLE);
- }
- }
- }
-
- int getListPosition(int position) {
- position -= getSystemRowCount() + getProfileRowCount();
-
- final int serviceCount = mChooserListAdapter.getServiceTargetCount();
- final int serviceRows = (int) Math.ceil((float) serviceCount / getMaxRankedTargets());
- if (position < serviceRows) {
- return position * mMaxTargetsPerRow;
- }
-
- position -= serviceRows;
-
- final int callerAndRankedCount = mChooserListAdapter.getCallerTargetCount()
- + mChooserListAdapter.getRankedTargetCount();
- final int callerAndRankedRows = getCallerAndRankedTargetRowCount();
- if (position < callerAndRankedRows) {
- return serviceCount + position * mMaxTargetsPerRow;
- }
-
- position -= getAzLabelRowCount() + callerAndRankedRows;
-
- return callerAndRankedCount + serviceCount + position;
- }
-
- public void handleScroll(View v, int y, int oldy) {
- boolean canExpandDirectShare = canExpandDirectShare();
- if (mDirectShareViewHolder != null && canExpandDirectShare) {
- mDirectShareViewHolder.handleScroll(
- mChooserMultiProfilePagerAdapter.getActiveAdapterView(), y, oldy,
- mMaxTargetsPerRow);
- }
- }
-
- /**
- * Only expand direct share area if there is a minimum number of targets.
- */
- private boolean canExpandDirectShare() {
- // Do not enable until we have confirmed more apps are using sharing shortcuts
- // Check git history for enablement logic
- return false;
- }
-
- public ChooserListAdapter getListAdapter() {
- return mChooserListAdapter;
- }
-
- boolean shouldCellSpan(int position) {
- return getItemViewType(position) == VIEW_TYPE_NORMAL;
- }
-
- void updateDirectShareExpansion() {
- if (mDirectShareViewHolder == null || !canExpandDirectShare()) {
- return;
- }
- RecyclerView activeAdapterView =
- mChooserMultiProfilePagerAdapter.getActiveAdapterView();
- if (mResolverDrawerLayout.isCollapsed()) {
- mDirectShareViewHolder.collapse(activeAdapterView);
- } else {
- mDirectShareViewHolder.expand(activeAdapterView);
- }
- }
- }
-
- /**
- * Used to bind types for group of items including:
- * {@link ChooserGridAdapter#VIEW_TYPE_DIRECT_SHARE},
- * and {@link ChooserGridAdapter#VIEW_TYPE_CALLER_AND_RANK}.
- */
- abstract static class ItemGroupViewHolder extends ViewHolderBase {
- protected int mMeasuredRowHeight;
- private int[] mItemIndices;
- protected final View[] mCells;
- private final int mColumnCount;
-
- ItemGroupViewHolder(int cellCount, View itemView, int viewType) {
- super(itemView, viewType);
- this.mCells = new View[cellCount];
- this.mItemIndices = new int[cellCount];
- this.mColumnCount = cellCount;
- }
-
- abstract ViewGroup addView(int index, View v);
-
- abstract ViewGroup getViewGroup();
-
- abstract ViewGroup getRowByIndex(int index);
-
- abstract ViewGroup getRow(int rowNumber);
-
- abstract void setViewVisibility(int i, int visibility);
-
- public int getColumnCount() {
- return mColumnCount;
- }
-
- public void measure() {
- final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
- getViewGroup().measure(spec, spec);
- mMeasuredRowHeight = getViewGroup().getMeasuredHeight();
- }
-
- public int getMeasuredRowHeight() {
- return mMeasuredRowHeight;
- }
-
- public void setItemIndex(int itemIndex, int listIndex) {
- mItemIndices[itemIndex] = listIndex;
- }
-
- public int getItemIndex(int itemIndex) {
- return mItemIndices[itemIndex];
- }
-
- public View getView(int index) {
- return mCells[index];
- }
- }
-
- static class SingleRowViewHolder extends ItemGroupViewHolder {
- private final ViewGroup mRow;
-
- SingleRowViewHolder(ViewGroup row, int cellCount, int viewType) {
- super(cellCount, row, viewType);
-
- this.mRow = row;
- }
-
- public ViewGroup getViewGroup() {
- return mRow;
- }
-
- public ViewGroup getRowByIndex(int index) {
- return mRow;
- }
-
- public ViewGroup getRow(int rowNumber) {
- if (rowNumber == 0) return mRow;
- return null;
- }
-
- public ViewGroup addView(int index, View v) {
- mRow.addView(v);
- mCells[index] = v;
-
- return mRow;
- }
-
- public void setViewVisibility(int i, int visibility) {
- getView(i).setVisibility(visibility);
- }
- }
-
- static class DirectShareViewHolder extends ItemGroupViewHolder {
- private final ViewGroup mParent;
- private final List<ViewGroup> mRows;
- private int mCellCountPerRow;
-
- private boolean mHideDirectShareExpansion = false;
- private int mDirectShareMinHeight = 0;
- private int mDirectShareCurrHeight = 0;
- private int mDirectShareMaxHeight = 0;
-
- private final boolean[] mCellVisibility;
-
- private final Supplier<ChooserListAdapter> mListAdapterSupplier;
-
- DirectShareViewHolder(ViewGroup parent, List<ViewGroup> rows, int cellCountPerRow,
- int viewType, Supplier<ChooserListAdapter> listAdapterSupplier) {
- super(rows.size() * cellCountPerRow, parent, viewType);
-
- this.mParent = parent;
- this.mRows = rows;
- this.mCellCountPerRow = cellCountPerRow;
- this.mCellVisibility = new boolean[rows.size() * cellCountPerRow];
- this.mListAdapterSupplier = listAdapterSupplier;
- }
-
- public ViewGroup addView(int index, View v) {
- ViewGroup row = getRowByIndex(index);
- row.addView(v);
- mCells[index] = v;
-
- return row;
- }
-
- public ViewGroup getViewGroup() {
- return mParent;
- }
-
- public ViewGroup getRowByIndex(int index) {
- return mRows.get(index / mCellCountPerRow);
- }
-
- public ViewGroup getRow(int rowNumber) {
- return mRows.get(rowNumber);
- }
-
- public void measure() {
- final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
- getRow(0).measure(spec, spec);
- getRow(1).measure(spec, spec);
-
- mDirectShareMinHeight = getRow(0).getMeasuredHeight();
- mDirectShareCurrHeight = mDirectShareCurrHeight > 0
- ? mDirectShareCurrHeight : mDirectShareMinHeight;
- mDirectShareMaxHeight = 2 * mDirectShareMinHeight;
- }
-
- public int getMeasuredRowHeight() {
- return mDirectShareCurrHeight;
- }
-
- public int getMinRowHeight() {
- return mDirectShareMinHeight;
- }
-
- public void setViewVisibility(int i, int visibility) {
- final View v = getView(i);
- if (visibility == View.VISIBLE) {
- mCellVisibility[i] = true;
- v.setVisibility(visibility);
- v.setAlpha(1.0f);
- } else if (visibility == View.INVISIBLE && mCellVisibility[i]) {
- mCellVisibility[i] = false;
-
- ValueAnimator fadeAnim = ObjectAnimator.ofFloat(v, "alpha", 1.0f, 0f);
- fadeAnim.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS);
- fadeAnim.setInterpolator(new AccelerateInterpolator(1.0f));
- fadeAnim.addListener(new AnimatorListenerAdapter() {
- public void onAnimationEnd(Animator animation) {
- v.setVisibility(View.INVISIBLE);
- }
- });
- fadeAnim.start();
- }
- }
-
- public void handleScroll(RecyclerView view, int y, int oldy, int maxTargetsPerRow) {
- // only exit early if fully collapsed, otherwise onListRebuilt() with shifting
- // targets can lock us into an expanded mode
- boolean notExpanded = mDirectShareCurrHeight == mDirectShareMinHeight;
- if (notExpanded) {
- if (mHideDirectShareExpansion) {
- return;
- }
-
- // only expand if we have more than maxTargetsPerRow, and delay that decision
- // until they start to scroll
- ChooserListAdapter adapter = mListAdapterSupplier.get();
- int validTargets = adapter.getSelectableServiceTargetCount();
- if (validTargets <= maxTargetsPerRow) {
- mHideDirectShareExpansion = true;
- return;
- }
- }
-
- int yDiff = (int) ((oldy - y) * DIRECT_SHARE_EXPANSION_RATE);
-
- int prevHeight = mDirectShareCurrHeight;
- int newHeight = Math.min(prevHeight + yDiff, mDirectShareMaxHeight);
- newHeight = Math.max(newHeight, mDirectShareMinHeight);
- yDiff = newHeight - prevHeight;
-
- updateDirectShareRowHeight(view, yDiff, newHeight);
- }
-
- void expand(RecyclerView view) {
- updateDirectShareRowHeight(view, mDirectShareMaxHeight - mDirectShareCurrHeight,
- mDirectShareMaxHeight);
- }
-
- void collapse(RecyclerView view) {
- updateDirectShareRowHeight(view, mDirectShareMinHeight - mDirectShareCurrHeight,
- mDirectShareMinHeight);
- }
-
- private void updateDirectShareRowHeight(RecyclerView view, int yDiff, int newHeight) {
- if (view == null || view.getChildCount() == 0 || yDiff == 0) {
- return;
- }
-
- // locate the item to expand, and offset the rows below that one
- boolean foundExpansion = false;
- for (int i = 0; i < view.getChildCount(); i++) {
- View child = view.getChildAt(i);
-
- if (foundExpansion) {
- child.offsetTopAndBottom(yDiff);
- } else {
- if (child.getTag() != null && child.getTag() instanceof DirectShareViewHolder) {
- int widthSpec = MeasureSpec.makeMeasureSpec(child.getWidth(),
- MeasureSpec.EXACTLY);
- int heightSpec = MeasureSpec.makeMeasureSpec(newHeight,
- MeasureSpec.EXACTLY);
- child.measure(widthSpec, heightSpec);
- child.getLayoutParams().height = child.getMeasuredHeight();
- child.layout(child.getLeft(), child.getTop(), child.getRight(),
- child.getTop() + child.getMeasuredHeight());
-
- foundExpansion = true;
- }
- }
- }
-
- if (foundExpansion) {
- mDirectShareCurrHeight = newHeight;
- }
- }
- }
-
- static class ServiceResultInfo {
- public final DisplayResolveInfo originalTarget;
- public final List<ChooserTarget> resultTargets;
- public final UserHandle userHandle;
-
- public ServiceResultInfo(DisplayResolveInfo ot, List<ChooserTarget> rt,
- UserHandle userHandle) {
- originalTarget = ot;
- resultTargets = rt;
- this.userHandle = userHandle;
- }
- }
-
static class ChooserTargetRankingInfo {
public final List<AppTarget> scores;
public final UserHandle userHandle;
@@ -3918,164 +2162,17 @@
}
/**
- * Used internally to round image corners while obeying view padding.
- */
- public static class RoundedRectImageView extends ImageView {
- private int mRadius = 0;
- private Path mPath = new Path();
- private Paint mOverlayPaint = new Paint(0);
- private Paint mRoundRectPaint = new Paint(0);
- private Paint mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private String mExtraImageCount = null;
-
- public RoundedRectImageView(Context context) {
- super(context);
- }
-
- public RoundedRectImageView(Context context, AttributeSet attrs) {
- this(context, attrs, 0);
- }
-
- public RoundedRectImageView(Context context, AttributeSet attrs, int defStyleAttr) {
- this(context, attrs, defStyleAttr, 0);
- }
-
- public RoundedRectImageView(Context context, AttributeSet attrs, int defStyleAttr,
- int defStyleRes) {
- super(context, attrs, defStyleAttr, defStyleRes);
- mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius);
-
- mOverlayPaint.setColor(0x99000000);
- mOverlayPaint.setStyle(Paint.Style.FILL);
-
- mRoundRectPaint.setColor(context.getResources().getColor(R.color.chooser_row_divider));
- mRoundRectPaint.setStyle(Paint.Style.STROKE);
- mRoundRectPaint.setStrokeWidth(context.getResources()
- .getDimensionPixelSize(R.dimen.chooser_preview_image_border));
-
- mTextPaint.setColor(Color.WHITE);
- mTextPaint.setTextSize(context.getResources()
- .getDimensionPixelSize(R.dimen.chooser_preview_image_font_size));
- mTextPaint.setTextAlign(Paint.Align.CENTER);
- }
-
- private void updatePath(int width, int height) {
- mPath.reset();
-
- int imageWidth = width - getPaddingRight() - getPaddingLeft();
- int imageHeight = height - getPaddingBottom() - getPaddingTop();
- mPath.addRoundRect(getPaddingLeft(), getPaddingTop(), imageWidth, imageHeight, mRadius,
- mRadius, Path.Direction.CW);
- }
-
- /**
- * Sets the corner radius on all corners
- *
- * param radius 0 for no radius, > 0 for a visible corner radius
- */
- public void setRadius(int radius) {
- mRadius = radius;
- updatePath(getWidth(), getHeight());
- }
-
- /**
- * Display an overlay with extra image count on 3rd image
- */
- public void setExtraImageCount(int count) {
- if (count > 0) {
- this.mExtraImageCount = "+" + count;
- } else {
- this.mExtraImageCount = null;
- }
- }
-
- @Override
- protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
- super.onSizeChanged(width, height, oldWidth, oldHeight);
- updatePath(width, height);
- }
-
- @Override
- protected void onDraw(Canvas canvas) {
- if (mRadius != 0) {
- canvas.clipPath(mPath);
- }
-
- super.onDraw(canvas);
-
- int x = getPaddingLeft();
- int y = getPaddingRight();
- int width = getWidth() - getPaddingRight() - getPaddingLeft();
- int height = getHeight() - getPaddingBottom() - getPaddingTop();
- if (mExtraImageCount != null) {
- canvas.drawRect(x, y, width, height, mOverlayPaint);
-
- int xPos = canvas.getWidth() / 2;
- int yPos = (int) ((canvas.getHeight() / 2.0f)
- - ((mTextPaint.descent() + mTextPaint.ascent()) / 2.0f));
-
- canvas.drawText(mExtraImageCount, xPos, yPos, mTextPaint);
- }
-
- canvas.drawRoundRect(x, y, width, height, mRadius, mRadius, mRoundRectPaint);
- }
- }
-
- /**
- * 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.
- */
- private class EnterTransitionAnimationDelegate implements View.OnLayoutChangeListener {
- private boolean mPreviewReady = false;
- private boolean mOffsetCalculated = false;
-
- void postponeTransition() {
- postponeEnterTransition();
- }
-
- void markImagePreviewReady() {
- if (!mPreviewReady) {
- mPreviewReady = true;
- maybeStartListenForLayout();
- }
- }
-
- void markOffsetCalculated() {
- if (!mOffsetCalculated) {
- mOffsetCalculated = true;
- maybeStartListenForLayout();
- }
- }
-
- private void maybeStartListenForLayout() {
- if (mPreviewReady && mOffsetCalculated && mResolverDrawerLayout != null) {
- if (mResolverDrawerLayout.isInLayout()) {
- startPostponedEnterTransition();
- } else {
- mResolverDrawerLayout.addOnLayoutChangeListener(this);
- mResolverDrawerLayout.requestLayout();
- }
- }
- }
-
- @Override
- public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft,
- int oldTop, int oldRight, int oldBottom) {
- v.removeOnLayoutChangeListener(this);
- startPostponedEnterTransition();
- }
- }
-
- /**
* Used in combination with the scene transition when launching the image editor
*/
private static class FinishAnimation extends AlphaAnimation implements
Animation.AnimationListener {
+ @Nullable
private Activity mActivity;
+ @Nullable
private View mRootView;
private final float mFromAlpha;
- FinishAnimation(Activity activity, View rootView) {
+ FinishAnimation(@NonNull Activity activity, @NonNull View rootView) {
super(rootView.getAlpha(), 0.0f);
mActivity = activity;
mRootView = rootView;
@@ -4099,7 +2196,9 @@
@Override
public void cancel() {
- mRootView.setAlpha(mFromAlpha);
+ if (mRootView != null) {
+ mRootView.setAlpha(mFromAlpha);
+ }
cleanup();
super.cancel();
}
@@ -4110,9 +2209,10 @@
@Override
public void onAnimationEnd(Animation animation) {
- if (mActivity != null) {
- mActivity.finish();
- cleanup();
+ Activity activity = mActivity;
+ cleanup();
+ if (activity != null) {
+ activity.finish();
}
}
@@ -4128,14 +2228,34 @@
@Override
protected void maybeLogProfileChange() {
- getChooserActivityLogger().logShareheetProfileChanged();
+ getChooserActivityLogger().logSharesheetProfileChanged();
}
- private boolean shouldNearbyShareBeFirstInRankedRow() {
- return ActivityManager.isLowRamDeviceStatic() && mIsNearbyShareFirstTargetInRankedApp;
- }
+ private static class ProfileRecord {
+ /** The {@link AppPredictor} for this profile, if any. */
+ @Nullable
+ public final AppPredictor appPredictor;
+ /**
+ * null if we should not load shortcuts.
+ */
+ @Nullable
+ public final ShortcutLoader shortcutLoader;
+ public long loadingStartTime;
- private boolean shouldNearbyShareBeIncludedAsActionButton() {
- return !shouldNearbyShareBeFirstInRankedRow();
+ private ProfileRecord(
+ @Nullable AppPredictor appPredictor,
+ @Nullable ShortcutLoader shortcutLoader) {
+ this.appPredictor = appPredictor;
+ this.shortcutLoader = shortcutLoader;
+ }
+
+ public void destroy() {
+ if (shortcutLoader != null) {
+ shortcutLoader.destroy();
+ }
+ if (appPredictor != null) {
+ appPredictor.destroy();
+ }
+ }
}
}
diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java
index 1daae01..9109bf9 100644
--- a/java/src/com/android/intentresolver/ChooserActivityLogger.java
+++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java
@@ -16,48 +16,228 @@
package com.android.intentresolver;
+import android.annotation.Nullable;
import android.content.Intent;
+import android.metrics.LogMaker;
+import android.net.Uri;
import android.provider.MediaStore;
+import android.util.HashedStringCache;
+import android.util.Log;
+import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.logging.InstanceId;
+import com.android.internal.logging.InstanceIdSequence;
+import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.UiEvent;
import com.android.internal.logging.UiEventLogger;
+import com.android.internal.logging.UiEventLoggerImpl;
+import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.android.internal.util.FrameworkStatsLog;
/**
- * Interface for writing Sharesheet atoms to statsd log.
+ * Helper for writing Sharesheet atoms to statsd log.
* @hide
*/
-public interface ChooserActivityLogger {
- /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */
- void logShareStarted(int eventId, String packageName, String mimeType, int appProvidedDirect,
- int appProvidedApp, boolean isWorkprofile, int previewType, String intent);
+public class ChooserActivityLogger {
+ private static final String TAG = "ChooserActivity";
+ private static final boolean DEBUG = true;
- /** Logs a UiEventReported event for the system sharesheet when the user selects a target. */
- void logShareTargetSelected(int targetType, String packageName, int positionPicked,
- boolean isPinned);
+ public static final int SELECTION_TYPE_SERVICE = 1;
+ public static final int SELECTION_TYPE_APP = 2;
+ public static final int SELECTION_TYPE_STANDARD = 3;
+ public static final int SELECTION_TYPE_COPY = 4;
+ public static final int SELECTION_TYPE_NEARBY = 5;
+ public static final int SELECTION_TYPE_EDIT = 6;
+
+ /**
+ * This shim is provided only for testing. In production, clients will only ever use a
+ * {@link DefaultFrameworkStatsLogger}.
+ */
+ @VisibleForTesting
+ interface FrameworkStatsLogger {
+ /** Overload to use for logging {@code FrameworkStatsLog.SHARESHEET_STARTED}. */
+ void write(
+ int frameworkEventId,
+ int appEventId,
+ String packageName,
+ int instanceId,
+ String mimeType,
+ int numAppProvidedDirectTargets,
+ int numAppProvidedAppTargets,
+ boolean isWorkProfile,
+ int previewType,
+ int intentType);
+
+ /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */
+ void write(
+ int frameworkEventId,
+ int appEventId,
+ String packageName,
+ int instanceId,
+ int positionPicked,
+ boolean isPinned);
+ }
+
+ private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13);
+
+ // A small per-notification ID, used for statsd logging.
+ // TODO: consider precomputing and storing as final.
+ private static InstanceIdSequence sInstanceIdSequence;
+ private InstanceId mInstanceId;
+
+ private final UiEventLogger mUiEventLogger;
+ private final FrameworkStatsLogger mFrameworkStatsLogger;
+ private final MetricsLogger mMetricsLogger;
+
+ public ChooserActivityLogger() {
+ this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger());
+ }
+
+ @VisibleForTesting
+ ChooserActivityLogger(
+ UiEventLogger uiEventLogger,
+ FrameworkStatsLogger frameworkLogger,
+ MetricsLogger metricsLogger) {
+ mUiEventLogger = uiEventLogger;
+ mFrameworkStatsLogger = frameworkLogger;
+ mMetricsLogger = metricsLogger;
+ }
+
+ /** Records metrics for the start time of the {@link ChooserActivity}. */
+ public void logChooserActivityShown(
+ boolean isWorkProfile, String targetMimeType, long systemCost) {
+ mMetricsLogger.write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN)
+ .setSubtype(
+ isWorkProfile ? MetricsEvent.MANAGED_PROFILE : MetricsEvent.PARENT_PROFILE)
+ .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, targetMimeType)
+ .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost));
+ }
+
+ /** 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) {
+ mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED,
+ /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(),
+ /* package_name = 2 */ packageName,
+ /* instance_id = 3 */ getInstanceId().getId(),
+ /* mime_type = 4 */ mimeType,
+ /* num_app_provided_direct_targets = 5 */ appProvidedDirect,
+ /* num_app_provided_app_targets = 6 */ appProvidedApp,
+ /* is_workprofile = 7 */ isWorkprofile,
+ /* previewType = 8 */ typeFromPreviewInt(previewType),
+ /* intentType = 9 */ typeFromIntentString(intent));
+ }
+
+ /**
+ * Logs a UiEventReported event for the system sharesheet when the user selects a target.
+ * TODO: document parameters and/or consider breaking up by targetType so we don't have to
+ * support an overly-generic signature.
+ */
+ public void logShareTargetSelected(
+ int targetType,
+ String packageName,
+ int positionPicked,
+ int directTargetAlsoRanked,
+ int numCallerProvided,
+ @Nullable HashedStringCache.HashResult directTargetHashed,
+ boolean isPinned,
+ boolean successfullySelected,
+ long selectionCost) {
+ mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED,
+ /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(),
+ /* package_name = 2 */ packageName,
+ /* instance_id = 3 */ getInstanceId().getId(),
+ /* position_picked = 4 */ positionPicked,
+ /* is_pinned = 5 */ isPinned);
+
+ int category = getTargetSelectionCategory(targetType);
+ if (category != 0) {
+ LogMaker targetLogMaker = new LogMaker(category).setSubtype(positionPicked);
+ if (directTargetHashed != null) {
+ targetLogMaker.addTaggedData(
+ MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString);
+ targetLogMaker.addTaggedData(
+ MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN,
+ directTargetHashed.saltGeneration);
+ targetLogMaker.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION,
+ directTargetAlsoRanked);
+ }
+ targetLogMaker.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, numCallerProvided);
+ mMetricsLogger.write(targetLogMaker);
+ }
+
+ if (successfullySelected) {
+ if (DEBUG) {
+ Log.d(TAG, "User Selection Time Cost is " + selectionCost);
+ Log.d(TAG, "position of selected app/service/caller is " + positionPicked);
+ }
+ MetricsLogger.histogram(
+ null, "user_selection_cost_for_smart_sharing", (int) selectionCost);
+ MetricsLogger.histogram(null, "app_position_for_smart_sharing", positionPicked);
+ }
+ }
+
+ /** Log when direct share targets were received. */
+ public void logDirectShareTargetReceived(int category, int latency) {
+ mMetricsLogger.write(new LogMaker(category).setSubtype(latency));
+ }
+
+ /**
+ * Log when we display a preview UI of the specified {@code previewType} as part of our
+ * Sharesheet session.
+ */
+ public void logActionShareWithPreview(int previewType) {
+ mMetricsLogger.write(
+ new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW).setSubtype(previewType));
+ }
+
+ /** Log when the user selects an action button with the specified {@code targetType}. */
+ public void logActionSelected(int targetType) {
+ if (targetType == SELECTION_TYPE_COPY) {
+ LogMaker targetLogMaker = new LogMaker(
+ MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET).setSubtype(1);
+ mMetricsLogger.write(targetLogMaker);
+ }
+ mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED,
+ /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(),
+ /* package_name = 2 */ "",
+ /* instance_id = 3 */ getInstanceId().getId(),
+ /* position_picked = 4 */ -1,
+ /* is_pinned = 5 */ false);
+ }
+
+ /** Log a warning that we couldn't display the content preview from the supplied {@code uri}. */
+ public 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");
+
+ }
/** Logs a UiEventReported event for the system sharesheet being triggered by the user. */
- default void logSharesheetTriggered() {
+ public void logSharesheetTriggered() {
log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId());
}
/** Logs a UiEventReported event for the system sharesheet completing loading app targets. */
- default void logSharesheetAppLoadComplete() {
+ public void logSharesheetAppLoadComplete() {
log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId());
}
/**
* Logs a UiEventReported event for the system sharesheet completing loading service targets.
*/
- default void logSharesheetDirectLoadComplete() {
+ public void logSharesheetDirectLoadComplete() {
log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId());
}
/**
* Logs a UiEventReported event for the system sharesheet timing out loading service targets.
*/
- default void logSharesheetDirectLoadTimeout() {
+ public void logSharesheetDirectLoadTimeout() {
log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId());
}
@@ -65,12 +245,12 @@
* Logs a UiEventReported event for the system sharesheet switching
* between work and main profile.
*/
- default void logShareheetProfileChanged() {
+ public void logSharesheetProfileChanged() {
log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId());
}
/** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */
- default void logSharesheetExpansionChanged(boolean isCollapsed) {
+ public void logSharesheetExpansionChanged(boolean isCollapsed) {
log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED :
SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId());
}
@@ -78,14 +258,14 @@
/**
* Logs a UiEventReported event for the system sharesheet app share ranking timing out.
*/
- default void logSharesheetAppShareRankingTimeout() {
+ public void logSharesheetAppShareRankingTimeout() {
log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId());
}
/**
* Logs a UiEventReported event for the system sharesheet when direct share row is empty.
*/
- default void logSharesheetEmptyDirectShareRow() {
+ public void logSharesheetEmptyDirectShareRow() {
log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId());
}
@@ -94,13 +274,26 @@
* @param event
* @param instanceId
*/
- void log(UiEventLogger.UiEventEnum event, InstanceId instanceId);
+ private void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) {
+ mUiEventLogger.logWithInstanceId(
+ event,
+ 0,
+ null,
+ instanceId);
+ }
/**
- *
- * @return
+ * @return A unique {@link InstanceId} to join across events recorded by this logger instance.
*/
- InstanceId getInstanceId();
+ private InstanceId getInstanceId() {
+ if (mInstanceId == null) {
+ if (sInstanceIdSequence == null) {
+ sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX);
+ }
+ mInstanceId = sInstanceIdSequence.newInstanceId();
+ }
+ return mInstanceId;
+ }
/**
* The UiEvent enums that this class can log.
@@ -147,17 +340,17 @@
public static SharesheetTargetSelectedEvent fromTargetType(int targetType) {
switch(targetType) {
- case ChooserActivity.SELECTION_TYPE_SERVICE:
+ case SELECTION_TYPE_SERVICE:
return SHARESHEET_SERVICE_TARGET_SELECTED;
- case ChooserActivity.SELECTION_TYPE_APP:
+ case SELECTION_TYPE_APP:
return SHARESHEET_APP_TARGET_SELECTED;
- case ChooserActivity.SELECTION_TYPE_STANDARD:
+ case SELECTION_TYPE_STANDARD:
return SHARESHEET_STANDARD_TARGET_SELECTED;
- case ChooserActivity.SELECTION_TYPE_COPY:
+ case SELECTION_TYPE_COPY:
return SHARESHEET_COPY_TARGET_SELECTED;
- case ChooserActivity.SELECTION_TYPE_NEARBY:
+ case SELECTION_TYPE_NEARBY:
return SHARESHEET_NEARBY_TARGET_SELECTED;
- case ChooserActivity.SELECTION_TYPE_EDIT:
+ case SELECTION_TYPE_EDIT:
return SHARESHEET_EDIT_TARGET_SELECTED;
default:
return INVALID;
@@ -201,13 +394,13 @@
/**
* Returns the enum used in sharesheet started atom to indicate what preview type was used.
*/
- default int typeFromPreviewInt(int previewType) {
+ private static int typeFromPreviewInt(int previewType) {
switch(previewType) {
- case ChooserActivity.CONTENT_PREVIEW_IMAGE:
+ case ChooserContentPreviewUi.CONTENT_PREVIEW_IMAGE:
return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE;
- case ChooserActivity.CONTENT_PREVIEW_FILE:
+ case ChooserContentPreviewUi.CONTENT_PREVIEW_FILE:
return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE;
- case ChooserActivity.CONTENT_PREVIEW_TEXT:
+ case ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT:
default:
return FrameworkStatsLog
.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN;
@@ -218,7 +411,7 @@
* Returns the enum used in sharesheet started atom to indicate what intent triggers the
* ChooserActivity.
*/
- default int typeFromIntentString(String intent) {
+ private static int typeFromIntentString(String intent) {
if (intent == null) {
return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT;
}
@@ -243,4 +436,62 @@
return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT;
}
}
+
+ @VisibleForTesting
+ static int getTargetSelectionCategory(int targetType) {
+ switch (targetType) {
+ case SELECTION_TYPE_SERVICE:
+ return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET;
+ case SELECTION_TYPE_APP:
+ return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET;
+ case SELECTION_TYPE_STANDARD:
+ return MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET;
+ default:
+ return 0;
+ }
+ }
+
+ private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger {
+ @Override
+ public void write(
+ int frameworkEventId,
+ int appEventId,
+ String packageName,
+ int instanceId,
+ String mimeType,
+ int numAppProvidedDirectTargets,
+ int numAppProvidedAppTargets,
+ boolean isWorkProfile,
+ int previewType,
+ int intentType) {
+ FrameworkStatsLog.write(
+ frameworkEventId,
+ /* event_id = 1 */ appEventId,
+ /* package_name = 2 */ packageName,
+ /* instance_id = 3 */ instanceId,
+ /* mime_type = 4 */ mimeType,
+ /* num_app_provided_direct_targets */ numAppProvidedDirectTargets,
+ /* num_app_provided_app_targets */ numAppProvidedAppTargets,
+ /* is_workprofile */ isWorkProfile,
+ /* previewType = 8 */ previewType,
+ /* intentType = 9 */ intentType);
+ }
+
+ @Override
+ public void write(
+ int frameworkEventId,
+ int appEventId,
+ String packageName,
+ int instanceId,
+ int positionPicked,
+ boolean isPinned) {
+ FrameworkStatsLog.write(
+ frameworkEventId,
+ /* event_id = 1 */ appEventId,
+ /* package_name = 2 */ packageName,
+ /* instance_id = 3 */ instanceId,
+ /* position_picked = 4 */ positionPicked,
+ /* is_pinned = 5 */ isPinned);
+ }
+ }
}
diff --git a/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java b/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java
deleted file mode 100644
index 08a345b..0000000
--- a/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * Copyright (C) 2020 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.internal.logging.InstanceId;
-import com.android.internal.logging.InstanceIdSequence;
-import com.android.internal.logging.UiEventLogger;
-import com.android.internal.logging.UiEventLoggerImpl;
-import com.android.internal.util.FrameworkStatsLog;
-
-/**
- * Standard implementation of ChooserActivityLogger interface.
- * @hide
- */
-public class ChooserActivityLoggerImpl implements ChooserActivityLogger {
- private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13);
-
- private UiEventLogger mUiEventLogger = new UiEventLoggerImpl();
- // A small per-notification ID, used for statsd logging.
- private InstanceId mInstanceId;
- private static InstanceIdSequence sInstanceIdSequence;
-
- @Override
- public void logShareStarted(int eventId, String packageName, String mimeType,
- int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType,
- String intent) {
- FrameworkStatsLog.write(FrameworkStatsLog.SHARESHEET_STARTED,
- /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(),
- /* package_name = 2 */ packageName,
- /* instance_id = 3 */ getInstanceId().getId(),
- /* mime_type = 4 */ mimeType,
- /* num_app_provided_direct_targets = 5 */ appProvidedDirect,
- /* num_app_provided_app_targets = 6 */ appProvidedApp,
- /* is_workprofile = 7 */ isWorkprofile,
- /* previewType = 8 */ typeFromPreviewInt(previewType),
- /* intentType = 9 */ typeFromIntentString(intent));
- }
-
- @Override
- public void logShareTargetSelected(int targetType, String packageName, int positionPicked,
- boolean isPinned) {
- FrameworkStatsLog.write(FrameworkStatsLog.RANKING_SELECTED,
- /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(),
- /* package_name = 2 */ packageName,
- /* instance_id = 3 */ getInstanceId().getId(),
- /* position_picked = 4 */ positionPicked,
- /* is_pinned = 5 */ isPinned);
- }
-
- @Override
- public void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) {
- mUiEventLogger.logWithInstanceId(
- event,
- 0,
- null,
- instanceId);
- }
-
- @Override
- public InstanceId getInstanceId() {
- if (mInstanceId == null) {
- if (sInstanceIdSequence == null) {
- sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX);
- }
- mInstanceId = sInstanceIdSequence.newInstanceId();
- }
- return mInstanceId;
- }
-
-}
diff --git a/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java
new file mode 100644
index 0000000..0b8dbe3
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserContentPreviewCoordinator.java
@@ -0,0 +1,132 @@
+/*
+ * 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
new file mode 100644
index 0000000..ff88e5e
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserContentPreviewUi.java
@@ -0,0 +1,566 @@
+/*
+ * 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/ChooserFlags.java b/java/src/com/android/intentresolver/ChooserFlags.java
deleted file mode 100644
index 67f9046..0000000
--- a/java/src/com/android/intentresolver/ChooserFlags.java
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright (C) 2019 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.prediction.AppPredictionManager;
-
-/**
- * Common flags for {@link ChooserListAdapter} and {@link ChooserActivity}.
- */
-public class ChooserFlags {
-
- /**
- * Whether to use {@link AppPredictionManager} to query for direct share targets (as opposed to
- * talking directly to {@link android.content.pm.ShortcutManager}.
- */
- // TODO(b/123089490): Replace with system flag
- static final boolean USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS = true;
-}
-
diff --git a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java
index 7c4b0c1..5f37352 100644
--- a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java
+++ b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java
@@ -19,8 +19,8 @@
import android.content.Context;
import android.util.AttributeSet;
-import com.android.internal.widget.GridLayoutManager;
-import com.android.internal.widget.RecyclerView;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
/**
* For a11y and per {@link RecyclerView#onInitializeAccessibilityNodeInfo}, override
diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java
index 6d0c833..699190f 100644
--- a/java/src/com/android/intentresolver/ChooserListAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserListAdapter.java
@@ -19,17 +19,22 @@
import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE;
import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER;
+import android.annotation.Nullable;
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.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.LabeledIntent;
+import android.content.pm.LauncherApps;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.ShortcutInfo;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
import android.os.AsyncTask;
import android.os.Trace;
import android.os.UserHandle;
@@ -42,27 +47,27 @@
import android.view.ViewGroup;
import android.widget.TextView;
+import androidx.annotation.WorkerThread;
+
import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
-import com.android.intentresolver.chooser.ChooserTargetInfo;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
+import com.android.intentresolver.chooser.NotSelectableTargetInfo;
import com.android.intentresolver.chooser.SelectableTargetInfo;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
public class ChooserListAdapter extends ResolverListAdapter {
private static final String TAG = "ChooserListAdapter";
private static final boolean DEBUG = false;
- private boolean mEnableStackedApps = true;
-
public static final int NO_POSITION = -1;
public static final int TARGET_BAD = -1;
public static final int TARGET_CALLER = 0;
@@ -71,40 +76,28 @@
public static final int TARGET_STANDARD_AZ = 3;
private static final int MAX_SUGGESTED_APP_TARGETS = 4;
- private static final int MAX_CHOOSER_TARGETS_PER_APP = 2;
/** {@link #getBaseScore} */
public static final float CALLER_TARGET_SCORE_BOOST = 900.f;
/** {@link #getBaseScore} */
public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f;
- private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f;
- private final int mMaxShortcutTargetsPerApp;
- private final ChooserListCommunicator mChooserListCommunicator;
- private final SelectableTargetInfo.SelectableTargetInfoCommunicator
- mSelectableTargetInfoCommunicator;
+ private final ChooserRequestParameters mChooserRequest;
+ private final int mMaxRankedTargets;
+
private final ChooserActivityLogger mChooserActivityLogger;
- private int mNumShortcutResults = 0;
private final Map<TargetInfo, AsyncTask> mIconLoaders = new HashMap<>();
- private boolean mApplySharingAppLimits;
// Reserve spots for incoming direct share targets by adding placeholders
- private ChooserTargetInfo
- mPlaceHolderTargetInfo = new ChooserActivity.PlaceHolderTargetInfo();
- private final List<ChooserTargetInfo> mServiceTargets = new ArrayList<>();
+ private final TargetInfo mPlaceHolderTargetInfo;
+ private final List<TargetInfo> mServiceTargets = new ArrayList<>();
private final List<DisplayResolveInfo> mCallerTargets = new ArrayList<>();
- private final ChooserActivity.BaseChooserTargetComparator mBaseTargetComparator =
- new ChooserActivity.BaseChooserTargetComparator();
- private boolean mListViewDataChanged = false;
+ private final ShortcutSelectionLogic mShortcutSelectionLogic;
// Sorted list of DisplayResolveInfos for the alphabetical app section.
private List<DisplayResolveInfo> mSortedList = new ArrayList<>();
- private AppPredictor mAppPredictor;
- private AppPredictor.Callback mAppPredictorCallback;
-
- private LoadDirectShareIconTaskProvider mTestLoadDirectShareTaskProvider;
// For pinned direct share labels, if the text spans multiple lines, the TextView will consume
// the full width, even if the characters actually take up less than that. Measure the actual
@@ -137,24 +130,47 @@
}
};
- public ChooserListAdapter(Context context, List<Intent> payloadIntents,
- Intent[] initialIntents, List<ResolveInfo> rList,
- boolean filterLastUsed, ResolverListController resolverListController,
- ChooserListCommunicator chooserListCommunicator,
- SelectableTargetInfo.SelectableTargetInfoCommunicator selectableTargetInfoCommunicator,
+ public ChooserListAdapter(
+ Context context,
+ List<Intent> payloadIntents,
+ Intent[] initialIntents,
+ List<ResolveInfo> rList,
+ boolean filterLastUsed,
+ ResolverListController resolverListController,
+ UserHandle userHandle,
+ Intent targetIntent,
+ ResolverListCommunicator resolverListCommunicator,
PackageManager packageManager,
- ChooserActivityLogger chooserActivityLogger) {
+ ChooserActivityLogger chooserActivityLogger,
+ ChooserRequestParameters chooserRequest,
+ int maxRankedTargets) {
// Don't send the initial intents through the shared ResolverActivity path,
// we want to separate them into a different section.
- super(context, payloadIntents, null, rList, filterLastUsed,
- resolverListController, chooserListCommunicator, false);
+ super(
+ context,
+ payloadIntents,
+ null,
+ rList,
+ filterLastUsed,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ false);
- mMaxShortcutTargetsPerApp =
- context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp);
- mChooserListCommunicator = chooserListCommunicator;
+ mChooserRequest = chooserRequest;
+ mMaxRankedTargets = maxRankedTargets;
+
+ mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context);
createPlaceHolders();
- mSelectableTargetInfoCommunicator = selectableTargetInfoCommunicator;
mChooserActivityLogger = chooserActivityLogger;
+ mShortcutSelectionLogic = new ShortcutSelectionLogic(
+ context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp),
+ DeviceConfig.getBoolean(
+ DeviceConfig.NAMESPACE_SYSTEMUI,
+ SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
+ true)
+ );
if (initialIntents != null) {
for (int i = 0; i < initialIntents.length; i++) {
@@ -172,7 +188,9 @@
final ComponentName cn = ii.getComponent();
if (cn != null) {
try {
- ai = packageManager.getActivityInfo(ii.getComponent(), 0);
+ ai = packageManager.getActivityInfo(
+ ii.getComponent(),
+ PackageManager.ComponentInfoFlags.of(PackageManager.GET_META_DATA));
ri = new ResolveInfo();
ri.activityInfo = ai;
} catch (PackageManager.NameNotFoundException ignored) {
@@ -182,7 +200,9 @@
if (ai == null) {
// Because of AIDL bug, resolveActivity can't accept subclasses of Intent.
final Intent rii = (ii.getClass() == Intent.class) ? ii : new Intent(ii);
- ri = packageManager.resolveActivity(rii, PackageManager.MATCH_DEFAULT_ONLY);
+ ri = packageManager.resolveActivity(
+ rii,
+ PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY));
ai = ri != null ? ri.activityInfo : null;
}
if (ai == null) {
@@ -203,18 +223,12 @@
ri.noResourceId = true;
ri.icon = 0;
}
- mCallerTargets.add(new DisplayResolveInfo(ii, ri, ii, makePresentationGetter(ri)));
+ DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo(
+ ii, ri, ii, mPresentationFactory.makePresentationGetter(ri));
+ mCallerTargets.add(displayResolveInfo);
if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break;
}
}
- mApplySharingAppLimits = DeviceConfig.getBoolean(
- DeviceConfig.NAMESPACE_SYSTEMUI,
- SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
- true);
- }
-
- AppPredictor getAppPredictor() {
- return mAppPredictor;
}
@Override
@@ -223,73 +237,54 @@
Log.d(TAG, "clearing queryTargets on package change");
}
createPlaceHolders();
- mChooserListCommunicator.onHandlePackagesChanged(this);
+ mResolverListCommunicator.onHandlePackagesChanged(this);
}
- @Override
- public void notifyDataSetChanged() {
- if (!mListViewDataChanged) {
- mChooserListCommunicator.sendListViewUpdateMessage(getUserHandle());
- mListViewDataChanged = true;
- }
- }
-
- void refreshListView() {
- if (mListViewDataChanged) {
- super.notifyDataSetChanged();
- }
- mListViewDataChanged = false;
- }
-
private void createPlaceHolders() {
- mNumShortcutResults = 0;
mServiceTargets.clear();
- for (int i = 0; i < mChooserListCommunicator.getMaxRankedTargets(); i++) {
+ for (int i = 0; i < mMaxRankedTargets; ++i) {
mServiceTargets.add(mPlaceHolderTargetInfo);
}
}
@Override
View onCreateView(ViewGroup parent) {
- return mInflater.inflate(
- R.layout.resolve_grid_item, parent, false);
+ return mInflater.inflate(R.layout.resolve_grid_item, parent, false);
}
+ @VisibleForTesting
@Override
- protected void onBindView(View view, TargetInfo info, int position) {
+ public void onBindView(View view, TargetInfo info, int position) {
final ViewHolder holder = (ViewHolder) view.getTag();
if (info == null) {
- holder.icon.setImageDrawable(
- mContext.getDrawable(R.drawable.resolver_icon_placeholder));
+ holder.icon.setImageDrawable(loadIconPlaceholder());
return;
}
- if (info instanceof DisplayResolveInfo) {
+ holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel());
+ holder.bindIcon(info);
+ if (info.isSelectableTargetInfo()) {
+ // direct share targets should append the application name for a better readout
+ DisplayResolveInfo rInfo = info.getDisplayResolveInfo();
+ CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : "";
+ CharSequence extendedInfo = info.getExtendedInfo();
+ String contentDescription = String.join(" ", info.getDisplayLabel(),
+ extendedInfo != null ? extendedInfo : "", appName);
+ holder.updateContentDescription(contentDescription);
+ if (!info.hasDisplayIcon()) {
+ loadDirectShareIcon((SelectableTargetInfo) info);
+ }
+ } else if (info.isDisplayResolveInfo()) {
DisplayResolveInfo dri = (DisplayResolveInfo) info;
- holder.bindLabel(dri.getDisplayLabel(), dri.getExtendedInfo(), alwaysShowSubLabel());
- startDisplayResolveInfoIconLoading(holder, dri);
- } else {
- holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel());
-
- if (info instanceof SelectableTargetInfo) {
- SelectableTargetInfo selectableInfo = (SelectableTargetInfo) info;
- // direct share targets should append the application name for a better readout
- DisplayResolveInfo rInfo = selectableInfo.getDisplayResolveInfo();
- CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : "";
- CharSequence extendedInfo = selectableInfo.getExtendedInfo();
- String contentDescription = String.join(" ", selectableInfo.getDisplayLabel(),
- extendedInfo != null ? extendedInfo : "", appName);
- holder.updateContentDescription(contentDescription);
- startSelectableTargetInfoIconLoading(holder, selectableInfo);
- } else {
- holder.bindIcon(info);
+ if (!dri.hasDisplayIcon()) {
+ loadIcon(dri);
}
}
// If target is loading, show a special placeholder shape in the label, make unclickable
- if (info instanceof ChooserActivity.PlaceHolderTargetInfo) {
+ if (info.isPlaceHolderTargetInfo()) {
final int maxWidth = mContext.getResources().getDimensionPixelSize(
R.dimen.chooser_direct_share_label_placeholder_max_width);
holder.text.setMaxWidth(maxWidth);
@@ -306,7 +301,7 @@
// Always remove the spacing listener, attach as needed to direct share targets below.
holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener);
- if (info instanceof MultiDisplayResolveInfo) {
+ if (info.isMultiDisplayResolveInfo()) {
// If the target is grouped show an indicator
Drawable bkg = mContext.getDrawable(R.drawable.chooser_group_background);
holder.text.setPaddingRelative(0, 0, bkg.getIntrinsicWidth() /* end */, 0);
@@ -325,64 +320,47 @@
}
}
- private void startDisplayResolveInfoIconLoading(ViewHolder holder, DisplayResolveInfo info) {
- LoadIconTask task = (LoadIconTask) mIconLoaders.get(info);
- if (task == null) {
- task = new LoadIconTask(info, holder);
- mIconLoaders.put(info, task);
- task.execute();
- } else {
- // The holder was potentially changed as the underlying items were
- // reshuffled, so reset the target holder
- task.setViewHolder(holder);
- }
- }
-
- private void startSelectableTargetInfoIconLoading(
- ViewHolder holder, SelectableTargetInfo info) {
+ private void loadDirectShareIcon(SelectableTargetInfo info) {
LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info);
if (task == null) {
- task = mTestLoadDirectShareTaskProvider == null
- ? new LoadDirectShareIconTask(info)
- : mTestLoadDirectShareTaskProvider.get();
+ task = createLoadDirectShareIconTask(info);
mIconLoaders.put(info, task);
task.loadIcon();
}
- task.setViewHolder(holder);
+ }
+
+ @VisibleForTesting
+ protected LoadDirectShareIconTask createLoadDirectShareIconTask(SelectableTargetInfo info) {
+ return new LoadDirectShareIconTask(
+ mContext.createContextAsUser(getUserHandle(), 0),
+ info);
}
void updateAlphabeticalList() {
+ // TODO: this procedure seems like it should be relatively lightweight. Why does it need to
+ // run in an `AsyncTask`?
new AsyncTask<Void, Void, List<DisplayResolveInfo>>() {
@Override
protected List<DisplayResolveInfo> doInBackground(Void... voids) {
List<DisplayResolveInfo> allTargets = new ArrayList<>();
- allTargets.addAll(mDisplayList);
+ allTargets.addAll(getTargetsInCurrentDisplayList());
allTargets.addAll(mCallerTargets);
- if (!mEnableStackedApps) {
- return allTargets;
- }
+
// Consolidate multiple targets from same app.
- Map<String, DisplayResolveInfo> consolidated = new HashMap<>();
- for (DisplayResolveInfo info : allTargets) {
- String resolvedTarget = info.getResolvedComponentName().getPackageName()
- + '#' + info.getDisplayLabel();
- DisplayResolveInfo multiDri = consolidated.get(resolvedTarget);
- if (multiDri == null) {
- consolidated.put(resolvedTarget, info);
- } else if (multiDri instanceof MultiDisplayResolveInfo) {
- ((MultiDisplayResolveInfo) multiDri).addTarget(info);
- } else {
- // create consolidated target from the single DisplayResolveInfo
- MultiDisplayResolveInfo multiDisplayResolveInfo =
- new MultiDisplayResolveInfo(resolvedTarget, multiDri);
- multiDisplayResolveInfo.addTarget(info);
- consolidated.put(resolvedTarget, multiDisplayResolveInfo);
- }
- }
- List<DisplayResolveInfo> groupedTargets = new ArrayList<>();
- groupedTargets.addAll(consolidated.values());
- Collections.sort(groupedTargets, new ChooserActivity.AzInfoComparator(mContext));
- return groupedTargets;
+ return allTargets
+ .stream()
+ .collect(Collectors.groupingBy(target ->
+ target.getResolvedComponentName().getPackageName()
+ + "#" + target.getDisplayLabel()
+ ))
+ .values()
+ .stream()
+ .map(appTargets ->
+ (appTargets.size() == 1)
+ ? appTargets.get(0)
+ : MultiDisplayResolveInfo.newMultiDisplayResolveInfo(appTargets))
+ .sorted(new ChooserActivity.AzInfoComparator(mContext))
+ .collect(Collectors.toList());
}
@Override
protected void onPostExecute(List<DisplayResolveInfo> newList) {
@@ -401,8 +379,9 @@
@Override
public int getUnfilteredCount() {
int appTargets = super.getUnfilteredCount();
- if (appTargets > mChooserListCommunicator.getMaxRankedTargets()) {
- appTargets = appTargets + mChooserListCommunicator.getMaxRankedTargets();
+ if (appTargets > mMaxRankedTargets) {
+ // TODO: what does this condition mean?
+ appTargets = appTargets + mMaxRankedTargets;
}
return appTargets + getSelectableServiceTargetCount() + getCallerTargetCount();
}
@@ -417,8 +396,8 @@
*/
public int getSelectableServiceTargetCount() {
int count = 0;
- for (ChooserTargetInfo info : mServiceTargets) {
- if (info instanceof SelectableTargetInfo) {
+ for (TargetInfo info : mServiceTargets) {
+ if (info.isSelectableTargetInfo()) {
count++;
}
}
@@ -426,29 +405,37 @@
}
public int getServiceTargetCount() {
- if (mChooserListCommunicator.isSendAction(mChooserListCommunicator.getTargetIntent())
- && !ActivityManager.isLowRamDeviceStatic()) {
- return Math.min(mServiceTargets.size(), mChooserListCommunicator.getMaxRankedTargets());
+ if (mChooserRequest.isSendActionTarget() && !ActivityManager.isLowRamDeviceStatic()) {
+ return Math.min(mServiceTargets.size(), mMaxRankedTargets);
}
return 0;
}
- int getAlphaTargetCount() {
+ public int getAlphaTargetCount() {
int groupedCount = mSortedList.size();
- int ungroupedCount = mCallerTargets.size() + mDisplayList.size();
- return ungroupedCount > mChooserListCommunicator.getMaxRankedTargets() ? groupedCount : 0;
+ int ungroupedCount = mCallerTargets.size() + getDisplayResolveInfoCount();
+ return (ungroupedCount > mMaxRankedTargets) ? groupedCount : 0;
}
/**
* Fetch ranked app target count
*/
public int getRankedTargetCount() {
- int spacesAvailable =
- mChooserListCommunicator.getMaxRankedTargets() - getCallerTargetCount();
+ int spacesAvailable = mMaxRankedTargets - getCallerTargetCount();
return Math.min(spacesAvailable, super.getCount());
}
+ /** Get all the {@link DisplayResolveInfo} data for our targets. */
+ public DisplayResolveInfo[] getDisplayResolveInfos() {
+ int size = getDisplayResolveInfoCount();
+ DisplayResolveInfo[] resolvedTargets = new DisplayResolveInfo[size];
+ for (int i = 0; i < size; i++) {
+ resolvedTargets[i] = getDisplayResolveInfo(i);
+ }
+ return resolvedTargets;
+ }
+
public int getPositionTargetType(int position) {
int offset = 0;
@@ -483,7 +470,6 @@
return targetInfoForPosition(position, true);
}
-
/**
* Find target info for a given position.
* Since ChooserActivity displays several sections of content, determine which
@@ -533,8 +519,8 @@
protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) {
// Checks if this info is already listed in callerTargets.
for (TargetInfo existingInfo : mCallerTargets) {
- if (mResolverListCommunicator
- .resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) {
+ if (mResolverListCommunicator.resolveInfoMatch(
+ dri.getResolveInfo(), existingInfo.getResolveInfo())) {
return false;
}
}
@@ -544,10 +530,9 @@
/**
* Fetch surfaced direct share target info
*/
- public List<ChooserTargetInfo> getSurfacedTargetInfo() {
- int maxSurfacedTargets = mChooserListCommunicator.getMaxRankedTargets();
+ public List<TargetInfo> getSurfacedTargetInfo() {
return mServiceTargets.subList(0,
- Math.min(maxSurfacedTargets, getSelectableServiceTargetCount()));
+ Math.min(mMaxRankedTargets, getSelectableServiceTargetCount()));
}
@@ -555,83 +540,36 @@
* Evaluate targets for inclusion in the direct share area. May not be included
* if score is too low.
*/
- public void addServiceResults(DisplayResolveInfo origTarget, List<ChooserTarget> targets,
+ public void addServiceResults(
+ @Nullable DisplayResolveInfo origTarget,
+ List<ChooserTarget> targets,
@ChooserActivity.ShareTargetType int targetType,
- Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos) {
- if (DEBUG) {
- Log.d(TAG, "addServiceResults " + origTarget.getResolvedComponentName() + ", "
- + targets.size()
- + " targets");
- }
- if (targets.size() == 0) {
+ Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos,
+ Map<ChooserTarget, AppTarget> directShareToAppTargets) {
+ // Avoid inserting any potentially late results.
+ if ((mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo()) {
return;
}
- final float baseScore = getBaseScore(origTarget, targetType);
- Collections.sort(targets, mBaseTargetComparator);
- final boolean isShortcutResult =
- (targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER
- || targetType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE);
- final int maxTargets = isShortcutResult ? mMaxShortcutTargetsPerApp
- : MAX_CHOOSER_TARGETS_PER_APP;
- final int targetsLimit = mApplySharingAppLimits ? Math.min(targets.size(), maxTargets)
- : targets.size();
- float lastScore = 0;
- boolean shouldNotify = false;
- for (int i = 0, count = targetsLimit; i < count; i++) {
- final ChooserTarget target = targets.get(i);
- float targetScore = target.getScore();
- if (mApplySharingAppLimits) {
- targetScore *= baseScore;
- if (i > 0 && targetScore >= lastScore) {
- // Apply a decay so that the top app can't crowd out everything else.
- // This incents ChooserTargetServices to define what's truly better.
- targetScore = lastScore * 0.95f;
- }
- }
- ShortcutInfo shortcutInfo = isShortcutResult ? directShareToShortcutInfos.get(target)
- : null;
- if ((shortcutInfo != null) && shortcutInfo.isPinned()) {
- targetScore += PINNED_SHORTCUT_TARGET_SCORE_BOOST;
- }
- UserHandle userHandle = getUserHandle();
- Context contextAsUser = mContext.createContextAsUser(userHandle, 0 /* flags */);
- boolean isInserted = insertServiceTarget(new SelectableTargetInfo(contextAsUser,
- origTarget, target, targetScore, mSelectableTargetInfoCommunicator,
- shortcutInfo));
-
- if (isInserted && isShortcutResult) {
- mNumShortcutResults++;
- }
-
- shouldNotify |= isInserted;
-
- if (DEBUG) {
- Log.d(TAG, " => " + target.toString() + " score=" + targetScore
- + " base=" + target.getScore()
- + " lastScore=" + lastScore
- + " baseScore=" + baseScore
- + " applyAppLimit=" + mApplySharingAppLimits);
- }
-
- lastScore = targetScore;
- }
-
- if (shouldNotify) {
+ boolean isShortcutResult = targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER
+ || targetType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE;
+ boolean isUpdated = mShortcutSelectionLogic.addServiceResults(
+ origTarget,
+ getBaseScore(origTarget, targetType),
+ targets,
+ isShortcutResult,
+ directShareToShortcutInfos,
+ directShareToAppTargets,
+ mContext.createContextAsUser(getUserHandle(), 0),
+ mChooserRequest.getTargetIntent(),
+ mChooserRequest.getReferrerFillInIntent(),
+ mMaxRankedTargets,
+ mServiceTargets);
+ if (isUpdated) {
notifyDataSetChanged();
}
}
/**
- * The return number have to exceed a minimum limit to make direct share area expandable. When
- * append direct share targets is enabled, return count of all available targets parking in the
- * memory; otherwise, it is shortcuts count which will help reduce the amount of visible
- * shuffling due to older-style direct share targets.
- */
- int getNumServiceTargetsForExpand() {
- return mNumShortcutResults;
- }
-
- /**
* Use the scoring system along with artificial boosts to create up to 4 distinct buckets:
* <ol>
* <li>App-supplied targets
@@ -659,54 +597,14 @@
* update the direct share area.
*/
public void completeServiceTargetLoading() {
- mServiceTargets.removeIf(o -> o instanceof ChooserActivity.PlaceHolderTargetInfo);
+ mServiceTargets.removeIf(o -> o.isPlaceHolderTargetInfo());
if (mServiceTargets.isEmpty()) {
- mServiceTargets.add(new ChooserActivity.EmptyTargetInfo());
+ mServiceTargets.add(NotSelectableTargetInfo.newEmptyTargetInfo());
mChooserActivityLogger.logSharesheetEmptyDirectShareRow();
}
notifyDataSetChanged();
}
- private boolean insertServiceTarget(ChooserTargetInfo chooserTargetInfo) {
- // Avoid inserting any potentially late results
- if (mServiceTargets.size() == 1
- && mServiceTargets.get(0) instanceof ChooserActivity.EmptyTargetInfo) {
- return false;
- }
-
- // Check for duplicates and abort if found
- for (ChooserTargetInfo otherTargetInfo : mServiceTargets) {
- if (chooserTargetInfo.isSimilar(otherTargetInfo)) {
- return false;
- }
- }
-
- int currentSize = mServiceTargets.size();
- final float newScore = chooserTargetInfo.getModifiedScore();
- for (int i = 0; i < Math.min(currentSize, mChooserListCommunicator.getMaxRankedTargets());
- i++) {
- final ChooserTargetInfo serviceTarget = mServiceTargets.get(i);
- if (serviceTarget == null) {
- mServiceTargets.set(i, chooserTargetInfo);
- return true;
- } else if (newScore > serviceTarget.getModifiedScore()) {
- mServiceTargets.add(i, chooserTargetInfo);
- return true;
- }
- }
-
- if (currentSize < mChooserListCommunicator.getMaxRankedTargets()) {
- mServiceTargets.add(chooserTargetInfo);
- return true;
- }
-
- return false;
- }
-
- public ChooserTarget getChooserTargetForValue(int value) {
- return mServiceTargets.get(value).getChooserTarget();
- }
-
protected boolean alwaysShowSubLabel() {
// Always show a subLabel for visual consistency across list items. Show an empty
// subLabel if the subLabel is the same as the label
@@ -728,8 +626,7 @@
protected List<ResolvedComponentInfo> doInBackground(
List<ResolvedComponentInfo>... params) {
Trace.beginSection("ChooserListAdapter#SortingTask");
- mResolverListController.topK(params[0],
- mChooserListCommunicator.getMaxRankedTargets());
+ mResolverListController.topK(params[0], mMaxRankedTargets);
Trace.endSection();
return params[0];
}
@@ -737,88 +634,95 @@
protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) {
processSortedList(sortedComponents, doPostProcessing);
if (doPostProcessing) {
- mChooserListCommunicator.updateProfileViewButton();
+ mResolverListCommunicator.updateProfileViewButton();
notifyDataSetChanged();
}
}
};
}
- public void setAppPredictor(AppPredictor appPredictor) {
- mAppPredictor = appPredictor;
- }
-
- public void setAppPredictorCallback(AppPredictor.Callback appPredictorCallback) {
- mAppPredictorCallback = appPredictorCallback;
- }
-
- public void destroyAppPredictor() {
- if (getAppPredictor() != null) {
- getAppPredictor().unregisterPredictionUpdates(mAppPredictorCallback);
- getAppPredictor().destroy();
- setAppPredictor(null);
- }
- }
-
- /**
- * An alias for onBindView to use with unit tests.
- */
- @VisibleForTesting
- public void testViewBind(View view, TargetInfo info, int position) {
- onBindView(view, info, position);
- }
-
- @VisibleForTesting
- public void setTestLoadDirectShareTaskProvider(LoadDirectShareIconTaskProvider provider) {
- mTestLoadDirectShareTaskProvider = provider;
- }
-
- /**
- * Necessary methods to communicate between {@link ChooserListAdapter}
- * and {@link ChooserActivity}.
- */
- @VisibleForTesting
- public interface ChooserListCommunicator extends ResolverListCommunicator {
-
- int getMaxRankedTargets();
-
- void sendListViewUpdateMessage(UserHandle userHandle);
-
- boolean isSendAction(Intent targetIntent);
- }
-
/**
* Loads direct share targets icons.
*/
@VisibleForTesting
- public class LoadDirectShareIconTask extends AsyncTask<Void, Void, Void> {
+ public class LoadDirectShareIconTask extends AsyncTask<Void, Void, Drawable> {
+ private final Context mContext;
private final SelectableTargetInfo mTargetInfo;
- private ViewHolder mViewHolder;
- private LoadDirectShareIconTask(SelectableTargetInfo targetInfo) {
+ private LoadDirectShareIconTask(Context context, SelectableTargetInfo targetInfo) {
+ mContext = context;
mTargetInfo = targetInfo;
}
@Override
- protected Void doInBackground(Void... voids) {
- mTargetInfo.loadIcon();
- return null;
+ protected Drawable doInBackground(Void... voids) {
+ Drawable drawable;
+ try {
+ drawable = getChooserTargetIconDrawable(
+ mContext,
+ mTargetInfo.getChooserTargetIcon(),
+ mTargetInfo.getChooserTargetComponentName(),
+ mTargetInfo.getDirectShareShortcutInfo());
+ } catch (Exception e) {
+ Log.e(TAG,
+ "Failed to load shortcut icon for "
+ + mTargetInfo.getChooserTargetComponentName(),
+ e);
+ drawable = loadIconPlaceholder();
+ }
+ return drawable;
}
@Override
- protected void onPostExecute(Void arg) {
- if (mViewHolder != null) {
- mViewHolder.bindIcon(mTargetInfo);
+ protected void onPostExecute(@Nullable Drawable icon) {
+ if (icon != null && !mTargetInfo.hasDisplayIcon()) {
+ mTargetInfo.getDisplayIconHolder().setDisplayIcon(icon);
notifyDataSetChanged();
}
}
- /**
- * Specifies a view holder that will be updated when the task is completed.
- */
- public void setViewHolder(ViewHolder viewHolder) {
- mViewHolder = viewHolder;
- mViewHolder.bindIcon(mTargetInfo);
+ @WorkerThread
+ private Drawable getChooserTargetIconDrawable(
+ Context context,
+ @Nullable Icon icon,
+ ComponentName targetComponentName,
+ @Nullable ShortcutInfo shortcutInfo) {
+ Drawable directShareIcon = null;
+
+ // First get the target drawable and associated activity info
+ if (icon != null) {
+ directShareIcon = icon.loadDrawable(context);
+ } else if (shortcutInfo != null) {
+ LauncherApps launcherApps = context.getSystemService(LauncherApps.class);
+ if (launcherApps != null) {
+ directShareIcon = launcherApps.getShortcutIconDrawable(shortcutInfo, 0);
+ }
+ }
+
+ if (directShareIcon == null) {
+ return null;
+ }
+
+ ActivityInfo info = null;
+ try {
+ info = context.getPackageManager().getActivityInfo(targetComponentName, 0);
+ } catch (PackageManager.NameNotFoundException error) {
+ Log.e(TAG, "Could not find activity associated with ChooserTarget");
+ }
+
+ if (info == null) {
+ return null;
+ }
+
+ // Now fetch app icon and raster with no badging even in work profile
+ Bitmap appIcon = mPresentationFactory.makePresentationGetter(info).getIconBitmap(null);
+
+ // Raster target drawable with appIcon as a badge
+ SimpleIconFactory sif = SimpleIconFactory.obtain(context);
+ Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon);
+ sif.recycle();
+
+ return new BitmapDrawable(context.getResources(), directShareBadgedIcon);
}
/**
@@ -828,16 +732,4 @@
execute();
}
}
-
- /**
- * An interface for the unit tests to override icon loading task creation
- */
- @VisibleForTesting
- public interface LoadDirectShareIconTaskProvider {
- /**
- * Provides an instance of the task.
- * @return
- */
- LoadDirectShareIconTask get();
- }
}
diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
index da78fc8..39d1fab 100644
--- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java
@@ -16,306 +16,159 @@
package com.android.intentresolver;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE;
-
-import android.annotation.Nullable;
-import android.app.admin.DevicePolicyManager;
import android.content.Context;
import android.os.UserHandle;
import android.view.LayoutInflater;
-import android.view.View;
import android.view.ViewGroup;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewpager.widget.PagerAdapter;
+
+import com.android.intentresolver.grid.ChooserGridAdapter;
import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.widget.GridLayoutManager;
-import com.android.internal.widget.PagerAdapter;
-import com.android.internal.widget.RecyclerView;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.Optional;
+import java.util.function.Supplier;
/**
* A {@link PagerAdapter} which describes the work and personal profile share sheet screens.
*/
@VisibleForTesting
-public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter {
+public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAdapter<
+ RecyclerView, ChooserGridAdapter, ChooserListAdapter> {
private static final int SINGLE_CELL_SPAN_SIZE = 1;
- private final ChooserProfileDescriptor[] mItems;
- private final boolean mIsSendAction;
- private int mBottomOffset;
- private int mMaxTargetsPerRow;
+ private final ChooserProfileAdapterBinder mAdapterBinder;
+ private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier;
- ChooserMultiProfilePagerAdapter(Context context,
- ChooserActivity.ChooserGridAdapter adapter,
- UserHandle personalProfileUserHandle,
+ ChooserMultiProfilePagerAdapter(
+ Context context,
+ ChooserGridAdapter adapter,
+ EmptyStateProvider emptyStateProvider,
+ QuietModeManager quietModeManager,
UserHandle workProfileUserHandle,
- boolean isSendAction, int maxTargetsPerRow) {
- super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle);
- mItems = new ChooserProfileDescriptor[] {
- createProfileDescriptor(adapter)
- };
- mIsSendAction = isSendAction;
- mMaxTargetsPerRow = maxTargetsPerRow;
+ int maxTargetsPerRow) {
+ this(
+ context,
+ new ChooserProfileAdapterBinder(maxTargetsPerRow),
+ ImmutableList.of(adapter),
+ emptyStateProvider,
+ quietModeManager,
+ /* defaultProfile= */ 0,
+ workProfileUserHandle,
+ new BottomPaddingOverrideSupplier(context));
}
- ChooserMultiProfilePagerAdapter(Context context,
- ChooserActivity.ChooserGridAdapter personalAdapter,
- ChooserActivity.ChooserGridAdapter workAdapter,
+ ChooserMultiProfilePagerAdapter(
+ Context context,
+ ChooserGridAdapter personalAdapter,
+ ChooserGridAdapter workAdapter,
+ EmptyStateProvider emptyStateProvider,
+ QuietModeManager quietModeManager,
@Profile int defaultProfile,
- UserHandle personalProfileUserHandle,
UserHandle workProfileUserHandle,
- boolean isSendAction, int maxTargetsPerRow) {
- super(context, /* currentPage */ defaultProfile, personalProfileUserHandle,
- workProfileUserHandle);
- mItems = new ChooserProfileDescriptor[] {
- createProfileDescriptor(personalAdapter),
- createProfileDescriptor(workAdapter)
- };
- mIsSendAction = isSendAction;
- mMaxTargetsPerRow = maxTargetsPerRow;
+ int maxTargetsPerRow) {
+ this(
+ context,
+ new ChooserProfileAdapterBinder(maxTargetsPerRow),
+ ImmutableList.of(personalAdapter, workAdapter),
+ emptyStateProvider,
+ quietModeManager,
+ defaultProfile,
+ workProfileUserHandle,
+ new BottomPaddingOverrideSupplier(context));
}
- private ChooserProfileDescriptor createProfileDescriptor(
- ChooserActivity.ChooserGridAdapter adapter) {
- final LayoutInflater inflater = LayoutInflater.from(getContext());
- final ViewGroup rootView =
- (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false);
- ChooserProfileDescriptor profileDescriptor =
- new ChooserProfileDescriptor(rootView, adapter);
- profileDescriptor.recyclerView.setAccessibilityDelegateCompat(
- new ChooserRecyclerViewAccessibilityDelegate(profileDescriptor.recyclerView));
- return profileDescriptor;
+ private ChooserMultiProfilePagerAdapter(
+ Context context,
+ ChooserProfileAdapterBinder adapterBinder,
+ ImmutableList<ChooserGridAdapter> gridAdapters,
+ EmptyStateProvider emptyStateProvider,
+ QuietModeManager quietModeManager,
+ @Profile int defaultProfile,
+ UserHandle workProfileUserHandle,
+ BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) {
+ super(
+ context,
+ gridAdapter -> gridAdapter.getListAdapter(),
+ adapterBinder,
+ gridAdapters,
+ emptyStateProvider,
+ quietModeManager,
+ defaultProfile,
+ workProfileUserHandle,
+ () -> makeProfileView(context),
+ bottomPaddingOverrideSupplier);
+ mAdapterBinder = adapterBinder;
+ mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier;
}
public void setMaxTargetsPerRow(int maxTargetsPerRow) {
- mMaxTargetsPerRow = maxTargetsPerRow;
+ mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow);
}
- RecyclerView getListViewForIndex(int index) {
- return getItem(index).recyclerView;
+ public void setEmptyStateBottomOffset(int bottomOffset) {
+ mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset);
}
- @Override
- ChooserProfileDescriptor getItem(int pageIndex) {
- return mItems[pageIndex];
+ private static ViewGroup makeProfileView(Context context) {
+ LayoutInflater inflater = LayoutInflater.from(context);
+ ViewGroup rootView = (ViewGroup) inflater.inflate(
+ R.layout.chooser_list_per_profile, null, false);
+ RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list);
+ recyclerView.setAccessibilityDelegateCompat(
+ new ChooserRecyclerViewAccessibilityDelegate(recyclerView));
+ return rootView;
}
- @Override
- int getItemCount() {
- return mItems.length;
- }
+ private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> {
+ private final Context mContext;
+ private int mBottomOffset;
- @Override
- @VisibleForTesting
- public ChooserActivity.ChooserGridAdapter getAdapterForIndex(int pageIndex) {
- return mItems[pageIndex].chooserGridAdapter;
- }
-
- @Override
- @Nullable
- ChooserListAdapter getListAdapterForUserHandle(UserHandle userHandle) {
- if (getActiveListAdapter().getUserHandle().equals(userHandle)) {
- return getActiveListAdapter();
- } else if (getInactiveListAdapter() != null
- && getInactiveListAdapter().getUserHandle().equals(userHandle)) {
- return getInactiveListAdapter();
+ BottomPaddingOverrideSupplier(Context context) {
+ mContext = context;
}
- return null;
- }
- @Override
- void setupListAdapter(int pageIndex) {
- final RecyclerView recyclerView = getItem(pageIndex).recyclerView;
- ChooserActivity.ChooserGridAdapter chooserGridAdapter =
- getItem(pageIndex).chooserGridAdapter;
- GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager();
- glm.setSpanCount(mMaxTargetsPerRow);
- glm.setSpanSizeLookup(
- new GridLayoutManager.SpanSizeLookup() {
- @Override
- public int getSpanSize(int position) {
- return chooserGridAdapter.shouldCellSpan(position)
- ? SINGLE_CELL_SPAN_SIZE
- : glm.getSpanCount();
- }
- });
- }
-
- @Override
- @VisibleForTesting
- public ChooserListAdapter getActiveListAdapter() {
- return getAdapterForIndex(getCurrentPage()).getListAdapter();
- }
-
- @Override
- @VisibleForTesting
- public ChooserListAdapter getInactiveListAdapter() {
- if (getCount() == 1) {
- return null;
+ public void setEmptyStateBottomOffset(int bottomOffset) {
+ mBottomOffset = bottomOffset;
}
- return getAdapterForIndex(1 - getCurrentPage()).getListAdapter();
- }
- @Override
- public ResolverListAdapter getPersonalListAdapter() {
- return getAdapterForIndex(PROFILE_PERSONAL).getListAdapter();
- }
-
- @Override
- @Nullable
- public ResolverListAdapter getWorkListAdapter() {
- return getAdapterForIndex(PROFILE_WORK).getListAdapter();
- }
-
- @Override
- ChooserActivity.ChooserGridAdapter getCurrentRootAdapter() {
- return getAdapterForIndex(getCurrentPage());
- }
-
- @Override
- RecyclerView getActiveAdapterView() {
- return getListViewForIndex(getCurrentPage());
- }
-
- @Override
- @Nullable
- RecyclerView getInactiveAdapterView() {
- if (getCount() == 1) {
- return null;
- }
- return getListViewForIndex(1 - getCurrentPage());
- }
-
- @Override
- String getMetricsCategory() {
- return ResolverActivity.METRICS_CATEGORY_CHOOSER;
- }
-
- @Override
- protected void showWorkProfileOffEmptyState(ResolverListAdapter activeListAdapter,
- View.OnClickListener listener) {
- showEmptyState(activeListAdapter,
- getWorkAppPausedTitle(),
- /* subtitle = */ null,
- listener);
- }
-
- @Override
- protected void showNoPersonalToWorkIntentsEmptyState(ResolverListAdapter activeListAdapter) {
- if (mIsSendAction) {
- showEmptyState(activeListAdapter,
- getCrossProfileBlockedTitle(),
- getCantShareWithWorkMessage());
- } else {
- showEmptyState(activeListAdapter,
- getCrossProfileBlockedTitle(),
- getCantAccessWorkMessage());
+ public Optional<Integer> get() {
+ int initialBottomPadding = mContext.getResources().getDimensionPixelSize(
+ R.dimen.resolver_empty_state_container_padding_bottom);
+ return Optional.of(initialBottomPadding + mBottomOffset);
}
}
- @Override
- protected void showNoWorkToPersonalIntentsEmptyState(ResolverListAdapter activeListAdapter) {
- if (mIsSendAction) {
- showEmptyState(activeListAdapter,
- getCrossProfileBlockedTitle(),
- getCantShareWithPersonalMessage());
- } else {
- showEmptyState(activeListAdapter,
- getCrossProfileBlockedTitle(),
- getCantAccessPersonalMessage());
+ private static class ChooserProfileAdapterBinder implements
+ AdapterBinder<RecyclerView, ChooserGridAdapter> {
+ private int mMaxTargetsPerRow;
+
+ ChooserProfileAdapterBinder(int maxTargetsPerRow) {
+ mMaxTargetsPerRow = maxTargetsPerRow;
}
- }
- @Override
- protected void showNoPersonalAppsAvailableEmptyState(ResolverListAdapter listAdapter) {
- showEmptyState(listAdapter, getNoPersonalAppsAvailableMessage(), /* subtitle= */ null);
+ public void setMaxTargetsPerRow(int maxTargetsPerRow) {
+ mMaxTargetsPerRow = maxTargetsPerRow;
+ }
- }
-
- @Override
- protected void showNoWorkAppsAvailableEmptyState(ResolverListAdapter listAdapter) {
- showEmptyState(listAdapter, getNoWorkAppsAvailableMessage(), /* subtitle = */ null);
- }
-
- private String getWorkAppPausedTitle() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_WORK_PAUSED_TITLE,
- () -> getContext().getString(R.string.resolver_turn_on_work_apps));
- }
-
- private String getCrossProfileBlockedTitle() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
- () -> getContext().getString(R.string.resolver_cross_profile_blocked));
- }
-
- private String getCantShareWithWorkMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_CANT_SHARE_WITH_WORK,
- () -> getContext().getString(
- R.string.resolver_cant_share_with_work_apps_explanation));
- }
-
- private String getCantShareWithPersonalMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_CANT_SHARE_WITH_PERSONAL,
- () -> getContext().getString(
- R.string.resolver_cant_share_with_personal_apps_explanation));
- }
-
- private String getCantAccessWorkMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_CANT_ACCESS_WORK,
- () -> getContext().getString(
- R.string.resolver_cant_access_work_apps_explanation));
- }
-
- private String getCantAccessPersonalMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_CANT_ACCESS_PERSONAL,
- () -> getContext().getString(
- R.string.resolver_cant_access_personal_apps_explanation));
- }
-
- private String getNoWorkAppsAvailableMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_NO_WORK_APPS,
- () -> getContext().getString(
- R.string.resolver_no_work_apps_available));
- }
-
- private String getNoPersonalAppsAvailableMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_NO_PERSONAL_APPS,
- () -> getContext().getString(
- R.string.resolver_no_personal_apps_available));
- }
-
-
- void setEmptyStateBottomOffset(int bottomOffset) {
- mBottomOffset = bottomOffset;
- }
-
- @Override
- protected void setupContainerPadding(View container) {
- int initialBottomPadding = getContext().getResources().getDimensionPixelSize(
- R.dimen.resolver_empty_state_container_padding_bottom);
- container.setPadding(container.getPaddingLeft(), container.getPaddingTop(),
- container.getPaddingRight(), initialBottomPadding + mBottomOffset);
- }
-
- class ChooserProfileDescriptor extends ProfileDescriptor {
- private ChooserActivity.ChooserGridAdapter chooserGridAdapter;
- private RecyclerView recyclerView;
- ChooserProfileDescriptor(ViewGroup rootView, ChooserActivity.ChooserGridAdapter adapter) {
- super(rootView);
- chooserGridAdapter = adapter;
- recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list);
+ @Override
+ public void bind(
+ RecyclerView recyclerView, ChooserGridAdapter chooserGridAdapter) {
+ GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager();
+ glm.setSpanCount(mMaxTargetsPerRow);
+ glm.setSpanSizeLookup(
+ new GridLayoutManager.SpanSizeLookup() {
+ @Override
+ public int getSpanSize(int position) {
+ return chooserGridAdapter.shouldCellSpan(position)
+ ? SINGLE_CELL_SPAN_SIZE
+ : glm.getSpanCount();
+ }
+ });
}
}
}
diff --git a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java
index 67571b4..250b682 100644
--- a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java
+++ b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java
@@ -22,8 +22,8 @@
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
-import com.android.internal.widget.RecyclerView;
-import com.android.internal.widget.RecyclerViewAccessibilityDelegate;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate;
class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate {
private final Rect mTempRect = new Rect();
diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java
new file mode 100644
index 0000000..81481bf
--- /dev/null
+++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java
@@ -0,0 +1,441 @@
+/*
+ * 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.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.IntentSender;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.os.PatternMatcher;
+import android.service.chooser.ChooserTarget;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.Pair;
+
+import com.google.common.collect.ImmutableList;
+
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collector;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Utility to parse and validate parameters from the client-supplied {@link Intent} that launched
+ * the Sharesheet {@link ChooserActivity}. The validated parameters are stored as immutable ivars.
+ *
+ * TODO: field nullability in this class reflects legacy use, and typically would indicate that the
+ * client's intent didn't provide the respective data. In some cases we may be able to provide
+ * defaults instead of nulls -- especially for methods that return nullable lists or arrays, if the
+ * client code could instead handle empty collections equally well.
+ *
+ * TODO: some of these fields (especially getTargetIntent() and any other getters that delegate to
+ * it internally) differ from the legacy model because they're computed directly from the initial
+ * Chooser intent, where in the past they've been relayed up to ResolverActivity and then retrieved
+ * through methods on the base class. The base always seems to return them exactly as they were
+ * provided, so this should be safe -- and clients can reasonably switch to retrieving through these
+ * parameters instead. For now, the other convention is still used in some places. Ideally we'd like
+ * to normalize on a single source of truth, but we'll have to clean up the delegation up to the
+ * resolver (or perhaps this needs to be a subclass of some `ResolverRequestParameters` class?).
+ */
+public class ChooserRequestParameters {
+ private static final String TAG = "ChooserActivity";
+
+ private static final int LAUNCH_FLAGS_FOR_SEND_ACTION =
+ Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
+
+ private final Intent mTarget;
+ private final Pair<CharSequence, Integer> mTitleSpec;
+ private final Intent mReferrerFillInIntent;
+ private final ImmutableList<ComponentName> mFilteredComponentNames;
+ private final ImmutableList<ChooserTarget> mCallerChooserTargets;
+ private final boolean mRetainInOnStop;
+
+ @Nullable
+ private final ImmutableList<Intent> mAdditionalTargets;
+
+ @Nullable
+ private final Bundle mReplacementExtras;
+
+ @Nullable
+ private final ImmutableList<Intent> mInitialIntents;
+
+ @Nullable
+ private final IntentSender mChosenComponentSender;
+
+ @Nullable
+ private final IntentSender mRefinementIntentSender;
+
+ @Nullable
+ private final String mSharedText;
+
+ @Nullable
+ private final IntentFilter mTargetIntentFilter;
+
+ public ChooserRequestParameters(
+ final Intent clientIntent,
+ final Uri referrer,
+ @Nullable final ComponentName nearbySharingComponent) {
+ final Intent requestedTarget = parseTargetIntentExtra(
+ clientIntent.getParcelableExtra(Intent.EXTRA_INTENT));
+ mTarget = intentWithModifiedLaunchFlags(requestedTarget);
+
+ mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent(
+ clientIntent, Intent.EXTRA_ALTERNATE_INTENTS);
+
+ mReplacementExtras = clientIntent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS);
+
+ mTitleSpec = makeTitleSpec(
+ clientIntent.getCharSequenceExtra(Intent.EXTRA_TITLE),
+ isSendAction(mTarget.getAction()));
+
+ mInitialIntents = intentsWithModifiedLaunchFlagsFromExtraIfPresent(
+ clientIntent, Intent.EXTRA_INITIAL_INTENTS);
+
+ mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer);
+
+ mChosenComponentSender = clientIntent.getParcelableExtra(
+ Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER);
+ mRefinementIntentSender = clientIntent.getParcelableExtra(
+ Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER);
+
+ mFilteredComponentNames = getFilteredComponentNames(clientIntent, nearbySharingComponent);
+
+ mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent);
+
+ mRetainInOnStop = clientIntent.getBooleanExtra(
+ ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false);
+
+ mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT);
+
+ mTargetIntentFilter = getTargetIntentFilter(mTarget);
+ }
+
+ public Intent getTargetIntent() {
+ return mTarget;
+ }
+
+ @Nullable
+ public String getTargetAction() {
+ return getTargetIntent().getAction();
+ }
+
+ public boolean isSendActionTarget() {
+ return isSendAction(getTargetAction());
+ }
+
+ @Nullable
+ public String getTargetType() {
+ return getTargetIntent().getType();
+ }
+
+ @Nullable
+ public CharSequence getTitle() {
+ return mTitleSpec.first;
+ }
+
+ public int getDefaultTitleResource() {
+ return mTitleSpec.second;
+ }
+
+ public Intent getReferrerFillInIntent() {
+ return mReferrerFillInIntent;
+ }
+
+ public ImmutableList<ComponentName> getFilteredComponentNames() {
+ return mFilteredComponentNames;
+ }
+
+ public ImmutableList<ChooserTarget> getCallerChooserTargets() {
+ return mCallerChooserTargets;
+ }
+
+ /**
+ * Whether the {@link ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested.
+ */
+ public boolean shouldRetainInOnStop() {
+ return mRetainInOnStop;
+ }
+
+ /**
+ * TODO: this returns a nullable array for convenience, but if the legacy APIs can be
+ * refactored, returning {@link mAdditionalTargets} directly is simpler and safer.
+ */
+ @Nullable
+ public Intent[] getAdditionalTargets() {
+ return (mAdditionalTargets == null) ? null : mAdditionalTargets.toArray(new Intent[0]);
+ }
+
+ @Nullable
+ public Bundle getReplacementExtras() {
+ return mReplacementExtras;
+ }
+
+ /**
+ * TODO: this returns a nullable array for convenience, but if the legacy APIs can be
+ * refactored, returning {@link mInitialIntents} directly is simpler and safer.
+ */
+ @Nullable
+ public Intent[] getInitialIntents() {
+ return (mInitialIntents == null) ? null : mInitialIntents.toArray(new Intent[0]);
+ }
+
+ @Nullable
+ public IntentSender getChosenComponentSender() {
+ return mChosenComponentSender;
+ }
+
+ @Nullable
+ public IntentSender getRefinementIntentSender() {
+ return mRefinementIntentSender;
+ }
+
+ @Nullable
+ public String getSharedText() {
+ return mSharedText;
+ }
+
+ @Nullable
+ public IntentFilter getTargetIntentFilter() {
+ return mTargetIntentFilter;
+ }
+
+ private static boolean isSendAction(@Nullable String action) {
+ return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action));
+ }
+
+ private static Intent parseTargetIntentExtra(@Nullable Parcelable targetParcelable) {
+ if (targetParcelable instanceof Uri) {
+ try {
+ targetParcelable = Intent.parseUri(targetParcelable.toString(),
+ Intent.URI_INTENT_SCHEME);
+ } catch (URISyntaxException ex) {
+ throw new IllegalArgumentException("Failed to parse EXTRA_INTENT from URI", ex);
+ }
+ }
+
+ if (!(targetParcelable instanceof Intent)) {
+ throw new IllegalArgumentException(
+ "EXTRA_INTENT is neither an Intent nor a Uri: " + targetParcelable);
+ }
+
+ return ((Intent) targetParcelable);
+ }
+
+ private static Intent intentWithModifiedLaunchFlags(Intent intent) {
+ if (isSendAction(intent.getAction())) {
+ intent.addFlags(LAUNCH_FLAGS_FOR_SEND_ACTION);
+ }
+ return intent;
+ }
+
+ /**
+ * Build a pair of values specifying the title to use from the client request. The first
+ * ({@link CharSequence}) value is the client-specified title, if there was one and their
+ * requested target <em>wasn't</em> a send action; otherwise it is null. The second value is
+ * the resource ID of a default title string; this is nonzero only if the first value is null.
+ *
+ * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate()}, or
+ * create a real type (not {@link Pair}) to express the semantics described in this comment.
+ */
+ private static Pair<CharSequence, Integer> makeTitleSpec(
+ @Nullable CharSequence requestedTitle, boolean hasSendActionTarget) {
+ if (hasSendActionTarget && (requestedTitle != null)) {
+ // Do not allow the title to be changed when sharing content
+ Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a"
+ + " preview title by using EXTRA_TITLE property of the wrapped"
+ + " EXTRA_INTENT.");
+ requestedTitle = null;
+ }
+
+ int defaultTitleRes =
+ (requestedTitle == null) ? com.android.internal.R.string.chooseActivity : 0;
+
+ return Pair.create(requestedTitle, defaultTitleRes);
+ }
+
+ private static ImmutableList<ComponentName> getFilteredComponentNames(
+ Intent clientIntent, @Nullable ComponentName nearbySharingComponent) {
+ Stream<ComponentName> filteredComponents = streamParcelableArrayExtra(
+ clientIntent, Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class, true, true);
+
+ if (nearbySharingComponent != null) {
+ // Exclude Nearby from main list if chip is present, to avoid duplication.
+ // TODO: we don't have an explicit guarantee that the chip will be displayed just
+ // because we have a non-null component; that's ultimately determined by the preview
+ // layout. Maybe we can make that decision further upstream?
+ filteredComponents = Stream.concat(
+ filteredComponents, Stream.of(nearbySharingComponent));
+ }
+
+ return filteredComponents.collect(toImmutableList());
+ }
+
+ private static ImmutableList<ChooserTarget> parseCallerTargetsFromClientIntent(
+ Intent clientIntent) {
+ return
+ streamParcelableArrayExtra(
+ clientIntent, Intent.EXTRA_CHOOSER_TARGETS, ChooserTarget.class, true, true)
+ .collect(toImmutableList());
+ }
+
+ private static <T> Collector<T, ?, ImmutableList<T>> toImmutableList() {
+ return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf);
+ }
+
+ @Nullable
+ private static ImmutableList<Intent> intentsWithModifiedLaunchFlagsFromExtraIfPresent(
+ Intent clientIntent, String extra) {
+ Stream<Intent> intents =
+ streamParcelableArrayExtra(clientIntent, extra, Intent.class, true, false);
+ if (intents == null) {
+ return null;
+ }
+ return intents
+ .map(ChooserRequestParameters::intentWithModifiedLaunchFlags)
+ .collect(toImmutableList());
+ }
+
+ /**
+ * Make a {@link Stream} of the {@link Parcelable} objects given in the provided {@link Intent}
+ * as the optional parcelable array extra with key {@code extra}. The stream elements, if any,
+ * are all of the type specified by {@code clazz}.
+ *
+ * @param intent The intent that may contain the optional extras.
+ * @param extra The extras key to identify the parcelable array.
+ * @param clazz A class that is assignable from any elements in the result stream.
+ * @param warnOnTypeError Whether to log a warning (and ignore) if the client extra doesn't have
+ * the required type. If false, throw an {@link IllegalArgumentException} if the extra is
+ * non-null but can't be assigned to variables of type {@code T}.
+ * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't
+ * present in the intent (or if it had the wrong type, but {@link warnOnTypeError} is true).
+ * If false, return null in these cases, and only return an empty stream if the intent
+ * explicitly provided an empty array for the specified extra.
+ */
+ @Nullable
+ private static <T extends Parcelable> Stream<T> streamParcelableArrayExtra(
+ final Intent intent,
+ String extra,
+ @NonNull Class<T> clazz,
+ boolean warnOnTypeError,
+ boolean streamEmptyIfNull) {
+ T[] result = null;
+
+ try {
+ result = getParcelableArrayExtraIfPresent(intent, extra, clazz);
+ } catch (IllegalArgumentException e) {
+ if (warnOnTypeError) {
+ Log.w(TAG, "Ignoring client-requested " + extra, e);
+ } else {
+ throw e;
+ }
+ }
+
+ if (result != null) {
+ return Arrays.stream(result);
+ } else if (streamEmptyIfNull) {
+ return Stream.empty();
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * If the specified {@code extra} is provided in the {@code intent}, cast it to type {@code T[]}
+ * or throw an {@code IllegalArgumentException} if the cast fails. If the {@code extra} isn't
+ * present in the {@code intent}, return null.
+ */
+ @Nullable
+ private static <T extends Parcelable> T[] getParcelableArrayExtraIfPresent(
+ final Intent intent, String extra, @NonNull Class<T> clazz) throws
+ IllegalArgumentException {
+ if (!intent.hasExtra(extra)) {
+ return null;
+ }
+
+ T[] castResult = intent.getParcelableArrayExtra(extra, clazz);
+ if (castResult == null) {
+ Parcelable[] actualExtrasArray = intent.getParcelableArrayExtra(extra);
+ if (actualExtrasArray != null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "%s is not of type %s[]: %s",
+ extra,
+ clazz.getSimpleName(),
+ Arrays.toString(actualExtrasArray)));
+ } else if (intent.getParcelableExtra(extra) != null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "%s is not of type %s[] (or any array type): %s",
+ extra,
+ clazz.getSimpleName(),
+ intent.getParcelableExtra(extra)));
+ } else {
+ throw new IllegalArgumentException(
+ String.format(
+ "%s is not of type %s (or any Parcelable type): %s",
+ extra,
+ clazz.getSimpleName(),
+ intent.getExtras().get(extra)));
+ }
+ }
+
+ return castResult;
+ }
+
+ private static IntentFilter getTargetIntentFilter(final Intent intent) {
+ try {
+ String dataString = intent.getDataString();
+ if (intent.getType() == null) {
+ if (!TextUtils.isEmpty(dataString)) {
+ return new IntentFilter(intent.getAction(), dataString);
+ }
+ Log.e(TAG, "Failed to get target intent filter: intent data and type are null");
+ return null;
+ }
+ IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType());
+ List<Uri> contentUris = new ArrayList<>();
+ if (Intent.ACTION_SEND.equals(intent.getAction())) {
+ Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
+ if (uri != null) {
+ contentUris.add(uri);
+ }
+ } else {
+ List<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+ if (uris != null) {
+ contentUris.addAll(uris);
+ }
+ }
+ for (Uri uri : contentUris) {
+ intentFilter.addDataScheme(uri.getScheme());
+ intentFilter.addDataAuthority(uri.getAuthority(), null);
+ intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL);
+ }
+ return intentFilter;
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to get target intent filter", e);
+ return null;
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java
index ae08ace..2cfceea 100644
--- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java
+++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java
@@ -20,9 +20,10 @@
import android.content.DialogInterface;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
-import android.os.Bundle;
import android.os.UserHandle;
+import androidx.fragment.app.FragmentManager;
+
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.MultiDisplayResolveInfo;
@@ -30,29 +31,39 @@
* Shows individual actions for a "stacked" app target - such as an app with multiple posting
* streams represented in the Sharesheet.
*/
-public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogFragment
- implements DialogInterface.OnClickListener {
+public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogFragment {
- static final String WHICH_KEY = "which_key";
- static final String MULTI_DRI_KEY = "multi_dri_key";
-
- private MultiDisplayResolveInfo mMultiDisplayResolveInfo;
- private int mParentWhich;
-
- public ChooserStackedAppDialogFragment() {}
-
- void setStateFromBundle(Bundle b) {
- mMultiDisplayResolveInfo = (MultiDisplayResolveInfo) b.get(MULTI_DRI_KEY);
- mTargetInfos = mMultiDisplayResolveInfo.getTargets();
- mUserHandle = (UserHandle) b.get(USER_HANDLE_KEY);
- mParentWhich = b.getInt(WHICH_KEY);
+ /**
+ * Display a fragment for the user to select one of the members of a target "stack."
+ * @param stackedTarget The display info for the full stack to select within.
+ * @param stackedTargetParentWhich The "which" value that the {@link ChooserActivity} uses to
+ * identify the {@code stackedTarget} as presented in the chooser menu UI. If the user selects
+ * a target in this fragment, the selection will be saved in the {@link MultiDisplayResolveInfo}
+ * and then the {@link ChooserActivity} will receive a {@code #startSelected()} callback using
+ * this "which" value to identify the stack that's now unambiguously resolved.
+ * @param userHandle
+ *
+ * TODO: consider taking a client-provided callback instead of {@code stackedTargetParentWhich}
+ * to avoid coupling with {@link ChooserActivity}'s mechanism for handling the selection.
+ */
+ public static void show(
+ FragmentManager fragmentManager,
+ MultiDisplayResolveInfo stackedTarget,
+ int stackedTargetParentWhich,
+ UserHandle userHandle) {
+ ChooserStackedAppDialogFragment fragment = new ChooserStackedAppDialogFragment(
+ stackedTarget, stackedTargetParentWhich, userHandle);
+ fragment.show(fragmentManager, TARGET_DETAILS_FRAGMENT_TAG);
}
+ private final MultiDisplayResolveInfo mMultiDisplayResolveInfo;
+ private final int mParentWhich;
+
@Override
- public void onSaveInstanceState(Bundle outState) {
- super.onSaveInstanceState(outState);
- outState.putInt(WHICH_KEY, mParentWhich);
- outState.putParcelable(MULTI_DRI_KEY, mMultiDisplayResolveInfo);
+ public void onClick(DialogInterface dialog, int which) {
+ mMultiDisplayResolveInfo.setSelected(which);
+ ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true);
+ dismiss();
}
@Override
@@ -63,15 +74,16 @@
@Override
protected Drawable getItemIcon(DisplayResolveInfo dri) {
-
// Show no icon for the group disambig dialog, null hides the imageview
return null;
}
- @Override
- public void onClick(DialogInterface dialog, int which) {
- mMultiDisplayResolveInfo.setSelected(which);
- ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true);
- dismiss();
+ private ChooserStackedAppDialogFragment(
+ MultiDisplayResolveInfo stackedTarget,
+ int stackedTargetParentWhich,
+ UserHandle userHandle) {
+ super(stackedTarget.getAllDisplayTargets(), userHandle);
+ mMultiDisplayResolveInfo = stackedTarget;
+ mParentWhich = stackedTargetParentWhich;
}
}
diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java
index ffd173c..0aa3250 100644
--- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java
+++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java
@@ -19,15 +19,12 @@
import static android.content.Context.ACTIVITY_SERVICE;
-import static com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter;
-
import static java.util.stream.Collectors.toList;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.Dialog;
-import android.app.DialogFragment;
import android.content.ComponentName;
import android.content.Context;
import android.content.DialogInterface;
@@ -49,11 +46,12 @@
import android.widget.ImageView;
import android.widget.TextView;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.recyclerview.widget.RecyclerView;
+
import com.android.intentresolver.chooser.DisplayResolveInfo;
-import com.android.internal.widget.RecyclerView;
-
-import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -64,68 +62,61 @@
public class ChooserTargetActionsDialogFragment extends DialogFragment
implements DialogInterface.OnClickListener {
- protected ArrayList<DisplayResolveInfo> mTargetInfos = new ArrayList<>();
- protected UserHandle mUserHandle;
- protected String mShortcutId;
- protected String mShortcutTitle;
- protected boolean mIsShortcutPinned;
- protected IntentFilter mIntentFilter;
+ protected final static String TARGET_DETAILS_FRAGMENT_TAG = "targetDetailsFragment";
- public static final String USER_HANDLE_KEY = "user_handle";
- public static final String TARGET_INFOS_KEY = "target_infos";
- public static final String SHORTCUT_ID_KEY = "shortcut_id";
- public static final String SHORTCUT_TITLE_KEY = "shortcut_title";
- public static final String IS_SHORTCUT_PINNED_KEY = "is_shortcut_pinned";
- public static final String INTENT_FILTER_KEY = "intent_filter";
+ private final List<DisplayResolveInfo> mTargetInfos;
+ private final UserHandle mUserHandle;
+ private final boolean mIsShortcutPinned;
- public ChooserTargetActionsDialogFragment() {}
+ @Nullable
+ private final String mShortcutId;
+
+ @Nullable
+ private final String mShortcutTitle;
+
+ @Nullable
+ private final IntentFilter mIntentFilter;
+
+ public static void show(
+ FragmentManager fragmentManager,
+ List<DisplayResolveInfo> targetInfos,
+ UserHandle userHandle,
+ @Nullable String shortcutId,
+ @Nullable String shortcutTitle,
+ boolean isShortcutPinned,
+ @Nullable IntentFilter intentFilter) {
+ ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment(
+ targetInfos,
+ userHandle,
+ shortcutId,
+ shortcutTitle,
+ isShortcutPinned,
+ intentFilter);
+ fragment.show(fragmentManager, TARGET_DETAILS_FRAGMENT_TAG);
+ }
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+
if (savedInstanceState != null) {
- setStateFromBundle(savedInstanceState);
- } else {
- setStateFromBundle(getArguments());
+ // Bail. It's probably not possible to trigger reloading our fragments from a saved
+ // instance since Sharesheet isn't kept in history and the entire session will probably
+ // be lost under any conditions that would've triggered our retention. Nevertheless, if
+ // we ever *did* try to load from a saved state, we wouldn't be able to populate valid
+ // data (since we wouldn't be able to get back our original TargetInfos if we had to
+ // restore them from a Bundle).
+ dismissAllowingStateLoss();
}
}
- void setStateFromBundle(Bundle b) {
- mTargetInfos = (ArrayList<DisplayResolveInfo>) b.get(TARGET_INFOS_KEY);
- mUserHandle = (UserHandle) b.get(USER_HANDLE_KEY);
- mShortcutId = b.getString(SHORTCUT_ID_KEY);
- mShortcutTitle = b.getString(SHORTCUT_TITLE_KEY);
- mIsShortcutPinned = b.getBoolean(IS_SHORTCUT_PINNED_KEY);
- mIntentFilter = (IntentFilter) b.get(INTENT_FILTER_KEY);
- }
-
- @Override
- public void onSaveInstanceState(Bundle outState) {
- super.onSaveInstanceState(outState);
-
- outState.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY,
- mUserHandle);
- outState.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY,
- mTargetInfos);
- outState.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY, mShortcutId);
- outState.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY,
- mIsShortcutPinned);
- outState.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, mShortcutTitle);
- outState.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY, mIntentFilter);
- }
-
/**
- * Recreate the layout from scratch to match new Sharesheet redlines
+ * Build the menu UI according to our design spec.
*/
@Override
public View onCreateView(LayoutInflater inflater,
@Nullable ViewGroup container,
Bundle savedInstanceState) {
- if (savedInstanceState != null) {
- setStateFromBundle(savedInstanceState);
- } else {
- setStateFromBundle(getArguments());
- }
// Make the background transparent to show dialog rounding
Optional.of(getDialog()).map(Dialog::getWindow)
.ifPresent(window -> {
@@ -143,7 +134,7 @@
ImageView icon = v.findViewById(com.android.internal.R.id.icon);
RecyclerView rv = v.findViewById(com.android.internal.R.id.listContainer);
- final ResolveInfoPresentationGetter pg = getProvidingAppPresentationGetter();
+ final TargetPresentationGetter pg = getProvidingAppPresentationGetter();
title.setText(isShortcutTarget() ? mShortcutTitle : pg.getLabel());
icon.setImageDrawable(pg.getIcon(mUserHandle));
rv.setAdapter(new VHAdapter(items));
@@ -277,14 +268,14 @@
return getPinIcon(isPinned(dri));
}
- private ResolveInfoPresentationGetter getProvidingAppPresentationGetter() {
+ private TargetPresentationGetter getProvidingAppPresentationGetter() {
final ActivityManager am = (ActivityManager) getContext()
.getSystemService(ACTIVITY_SERVICE);
final int iconDpi = am.getLauncherLargeIconDensity();
// Use the matching application icon and label for the title, any TargetInfo will do
- return new ResolveInfoPresentationGetter(getContext(), iconDpi,
- mTargetInfos.get(0).getResolveInfo());
+ return new TargetPresentationGetter.Factory(getContext(), iconDpi)
+ .makePresentationGetter(mTargetInfos.get(0).getResolveInfo());
}
private boolean isPinned(DisplayResolveInfo dri) {
@@ -294,4 +285,24 @@
private boolean isShortcutTarget() {
return mShortcutId != null;
}
+
+ protected ChooserTargetActionsDialogFragment(
+ List<DisplayResolveInfo> targetInfos, UserHandle userHandle) {
+ this(targetInfos, userHandle, null, null, false, null);
+ }
+
+ private ChooserTargetActionsDialogFragment(
+ List<DisplayResolveInfo> targetInfos,
+ UserHandle userHandle,
+ @Nullable String shortcutId,
+ @Nullable String shortcutTitle,
+ boolean isShortcutPinned,
+ @Nullable IntentFilter intentFilter) {
+ mTargetInfos = targetInfos;
+ mUserHandle = userHandle;
+ mShortcutId = shortcutId;
+ mShortcutTitle = shortcutTitle;
+ mIsShortcutPinned = isShortcutPinned;
+ mIntentFilter = intentFilter;
+ }
}
diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt
new file mode 100644
index 0000000..a0bf61b
--- /dev/null
+++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt
@@ -0,0 +1,107 @@
+/*
+ * 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.app.Activity
+import android.app.SharedElementCallback
+import android.view.View
+import com.android.intentresolver.widget.ResolverDrawerLayout
+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
+ private var previewReady = false
+ private var offsetCalculated = false
+
+ init {
+ activity.setEnterSharedElementCallback(
+ object : SharedElementCallback() {
+ override fun onMapSharedElements(
+ names: MutableList<String>, sharedElements: MutableMap<String, View>
+ ) {
+ this@EnterTransitionAnimationDelegate.onMapSharedElements(
+ names, sharedElements
+ )
+ }
+ })
+ }
+
+ fun postponeTransition() = activity.postponeEnterTransition()
+
+ fun markImagePreviewReady(runTransitionAnimation: Boolean) {
+ if (!runTransitionAnimation) {
+ removeSharedElements = true
+ }
+ if (!previewReady) {
+ previewReady = true
+ maybeStartListenForLayout()
+ }
+ }
+
+ fun markOffsetCalculated() {
+ if (!offsetCalculated) {
+ offsetCalculated = true
+ maybeStartListenForLayout()
+ }
+ }
+
+ private fun onMapSharedElements(
+ 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
+ }
+
+ private fun maybeStartListenForLayout() {
+ val drawer = resolverDrawerLayoutSupplier.get()
+ if (previewReady && offsetCalculated && drawer != null) {
+ if (drawer.isInLayout) {
+ startPostponedEnterTransition()
+ } else {
+ drawer.addOnLayoutChangeListener(this)
+ drawer.requestLayout()
+ }
+ }
+ }
+
+ override fun onLayoutChange(
+ v: View,
+ left: Int, top: Int, right: Int, bottom: Int,
+ oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int
+ ) {
+ v.removeOnLayoutChangeListener(this)
+ startPostponedEnterTransition()
+ }
+
+ private fun startPostponedEnterTransition() {
+ if (!removeSharedElements && activity.isActivityTransitionRunning) {
+ // Disable the window animations as it interferes with the transition animation.
+ activity.window.setWindowAnimations(0)
+ }
+ activity.startPostponedEnterTransition()
+ }
+}
diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
new file mode 100644
index 0000000..9bbdf7c
--- /dev/null
+++ b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java
@@ -0,0 +1,225 @@
+/*
+ * 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.content.Context;
+import android.os.UserHandle;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Implementation of {@link AbstractMultiProfilePagerAdapter} that consolidates the variation in
+ * existing implementations; most overrides were only to vary type signatures (which are better
+ * represented via generic types), and a few minor behavioral customizations are now implemented
+ * through small injectable delegate classes.
+ * TODO: now that the existing implementations are shown to be expressible in terms of this new
+ * generic type, merge up into the base class and simplify the public APIs.
+ * TODO: attempt to further restrict visibility in the methods we expose.
+ * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive"
+ * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident
+ * waiting to happen since clients seem to make assumptions about which adapter will be "active" in
+ * a particular context, and more explicit APIs would make sure those were valid.
+ * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?)
+ *
+ * @param <PageViewT> the type of the widget that represents the contents of a page in this adapter
+ * @param <SinglePageAdapterT> the type of a "root" adapter class to be instantiated and included in
+ * the per-profile records.
+ * @param <ListAdapterT> the concrete type of a {@link ResolverListAdapter} implementation to
+ * control the contents of a given per-profile list. This is provided for convenience, since it must
+ * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}.
+ *
+ * TODO: this class doesn't make any explicit usage of the {@link ResolverListAdapter} API, so the
+ * type constraint can probably be dropped once the API is merged upwards and cleaned.
+ */
+class GenericMultiProfilePagerAdapter<
+ PageViewT extends ViewGroup,
+ SinglePageAdapterT,
+ ListAdapterT extends ResolverListAdapter> extends AbstractMultiProfilePagerAdapter {
+
+ /** Delegate to set up a given adapter and page view to be used together. */
+ public interface AdapterBinder<PageViewT, SinglePageAdapterT> {
+ /**
+ * The given {@code view} will be associated with the given {@code adapter}. Do any work
+ * necessary to configure them compatibly, introduce them to each other, etc.
+ */
+ void bind(PageViewT view, SinglePageAdapterT adapter);
+ }
+
+ private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor;
+ private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder;
+ private final Supplier<ViewGroup> mPageViewInflater;
+ private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier;
+
+ private final ImmutableList<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems;
+
+ GenericMultiProfilePagerAdapter(
+ Context context,
+ Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor,
+ AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder,
+ ImmutableList<SinglePageAdapterT> adapters,
+ EmptyStateProvider emptyStateProvider,
+ QuietModeManager quietModeManager,
+ @Profile int defaultProfile,
+ UserHandle workProfileUserHandle,
+ Supplier<ViewGroup> pageViewInflater,
+ Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) {
+ super(
+ context,
+ /* currentPage= */ defaultProfile,
+ emptyStateProvider,
+ quietModeManager,
+ workProfileUserHandle);
+
+ mListAdapterExtractor = listAdapterExtractor;
+ mAdapterBinder = adapterBinder;
+ mPageViewInflater = pageViewInflater;
+ mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier;
+
+ ImmutableList.Builder<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> items =
+ new ImmutableList.Builder<>();
+ for (SinglePageAdapterT adapter : adapters) {
+ items.add(createProfileDescriptor(adapter));
+ }
+ mItems = items.build();
+ }
+
+ private GenericProfileDescriptor<PageViewT, SinglePageAdapterT>
+ createProfileDescriptor(SinglePageAdapterT adapter) {
+ return new GenericProfileDescriptor<>(mPageViewInflater.get(), adapter);
+ }
+
+ @Override
+ protected GenericProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) {
+ return mItems.get(pageIndex);
+ }
+
+ @Override
+ public int getItemCount() {
+ return mItems.size();
+ }
+
+ public PageViewT getListViewForIndex(int index) {
+ return getItem(index).mView;
+ }
+
+ @Override
+ @VisibleForTesting
+ public SinglePageAdapterT getAdapterForIndex(int index) {
+ return getItem(index).mAdapter;
+ }
+
+ @Override
+ protected void setupListAdapter(int pageIndex) {
+ mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex));
+ }
+
+ @Override
+ public ViewGroup instantiateItem(ViewGroup container, int position) {
+ setupListAdapter(position);
+ return super.instantiateItem(container, position);
+ }
+
+ @Override
+ @Nullable
+ protected ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) {
+ if (getActiveListAdapter().getUserHandle().equals(userHandle)) {
+ return getActiveListAdapter();
+ }
+ if ((getInactiveListAdapter() != null) && getInactiveListAdapter().getUserHandle().equals(
+ userHandle)) {
+ return getInactiveListAdapter();
+ }
+ return null;
+ }
+
+ @Override
+ @VisibleForTesting
+ public ListAdapterT getActiveListAdapter() {
+ return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage()));
+ }
+
+ @Override
+ @VisibleForTesting
+ public ListAdapterT getInactiveListAdapter() {
+ if (getCount() < 2) {
+ return null;
+ }
+ return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage()));
+ }
+
+ @Override
+ public ListAdapterT getPersonalListAdapter() {
+ return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL));
+ }
+
+ @Override
+ public ListAdapterT getWorkListAdapter() {
+ return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK));
+ }
+
+ @Override
+ protected SinglePageAdapterT getCurrentRootAdapter() {
+ return getAdapterForIndex(getCurrentPage());
+ }
+
+ @Override
+ protected PageViewT getActiveAdapterView() {
+ return getListViewForIndex(getCurrentPage());
+ }
+
+ @Override
+ protected PageViewT getInactiveAdapterView() {
+ if (getCount() < 2) {
+ return null;
+ }
+ return getListViewForIndex(1 - getCurrentPage());
+ }
+
+ @Override
+ protected void setupContainerPadding(View container) {
+ Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get();
+ bottomPaddingOverride.ifPresent(paddingBottom ->
+ container.setPadding(
+ container.getPaddingLeft(),
+ container.getPaddingTop(),
+ container.getPaddingRight(),
+ paddingBottom));
+ }
+
+ // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager"
+ // should be the owner of all per-profile data (especially now that the API is generic)?
+ private static class GenericProfileDescriptor<PageViewT, SinglePageAdapterT> extends
+ ProfileDescriptor {
+ private final SinglePageAdapterT mAdapter;
+ private final PageViewT mView;
+
+ GenericProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) {
+ super(rootView);
+ mAdapter = adapter;
+ mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list);
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/ImagePreviewImageLoader.kt
new file mode 100644
index 0000000..e68eb66
--- /dev/null
+++ b/java/src/com/android/intentresolver/ImagePreviewImageLoader.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 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) {
+ }
+ }
+ previewCoordinator.loadImage(uri, callback)
+ }
+}
diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java
index 9b853c9..7824025 100644
--- a/java/src/com/android/intentresolver/IntentForwarderActivity.java
+++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java
@@ -28,7 +28,6 @@
import android.app.ActivityThread;
import android.app.AppGlobals;
import android.app.admin.DevicePolicyManager;
-import android.compat.annotation.UnsupportedAppUsage;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Intent;
@@ -38,7 +37,6 @@
import android.content.pm.ResolveInfo;
import android.content.pm.UserInfo;
import android.metrics.LogMaker;
-import android.os.Build;
import android.os.Bundle;
import android.os.RemoteException;
import android.os.UserHandle;
@@ -65,7 +63,6 @@
* be passed in and out of a managed profile.
*/
public class IntentForwarderActivity extends Activity {
- @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
public static String TAG = "IntentForwarderActivity";
public static String FORWARD_INTENT_TO_PARENT
diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
new file mode 100644
index 0000000..5bf994d
--- /dev/null
+++ b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java
@@ -0,0 +1,154 @@
+/*
+ * 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 android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS;
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.admin.DevicePolicyEventLogger;
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.content.pm.ResolveInfo;
+import android.os.UserHandle;
+import android.stats.devicepolicy.nano.DevicePolicyEnums;
+
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider;
+import com.android.internal.R;
+
+import java.util.List;
+
+/**
+ * Chooser/ResolverActivity empty state provider that returns empty state which is shown when
+ * there are no apps available.
+ */
+public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider {
+
+ @NonNull
+ private final Context mContext;
+ @Nullable
+ private final UserHandle mWorkProfileUserHandle;
+ @Nullable
+ private final UserHandle mPersonalProfileUserHandle;
+ @NonNull
+ private final String mMetricsCategory;
+ @NonNull
+ private final MyUserIdProvider mMyUserIdProvider;
+
+ public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle,
+ UserHandle personalProfileUserHandle, String metricsCategory,
+ MyUserIdProvider myUserIdProvider) {
+ mContext = context;
+ mWorkProfileUserHandle = workProfileUserHandle;
+ mPersonalProfileUserHandle = personalProfileUserHandle;
+ mMetricsCategory = metricsCategory;
+ mMyUserIdProvider = myUserIdProvider;
+ }
+
+ @Nullable
+ @Override
+ @SuppressWarnings("ReferenceEquality")
+ public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
+ UserHandle listUserHandle = resolverListAdapter.getUserHandle();
+
+ if (mWorkProfileUserHandle != null
+ && (mMyUserIdProvider.getMyUserId() == listUserHandle.getIdentifier()
+ || !hasAppsInOtherProfile(resolverListAdapter))) {
+
+ String title;
+ if (listUserHandle == mPersonalProfileUserHandle) {
+ title = mContext.getSystemService(
+ DevicePolicyManager.class).getResources().getString(
+ RESOLVER_NO_PERSONAL_APPS,
+ () -> mContext.getString(R.string.resolver_no_personal_apps_available));
+ } else {
+ title = mContext.getSystemService(
+ DevicePolicyManager.class).getResources().getString(
+ RESOLVER_NO_WORK_APPS,
+ () -> mContext.getString(R.string.resolver_no_work_apps_available));
+ }
+
+ return new NoAppsAvailableEmptyState(
+ title, mMetricsCategory,
+ /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle
+ );
+ } else if (mWorkProfileUserHandle == null) {
+ // Return default empty state without tracking
+ return new DefaultEmptyState();
+ }
+
+ return null;
+ }
+
+ private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) {
+ if (mWorkProfileUserHandle == null) {
+ return false;
+ }
+ List<ResolverActivity.ResolvedComponentInfo> resolversForIntent =
+ adapter.getResolversForUser(UserHandle.of(mMyUserIdProvider.getMyUserId()));
+ for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) {
+ ResolveInfo resolveInfo = info.getResolveInfoAt(0);
+ if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static class DefaultEmptyState implements EmptyState {
+ @Override
+ public boolean useDefaultEmptyView() {
+ return true;
+ }
+ }
+
+ public static class NoAppsAvailableEmptyState implements EmptyState {
+
+ @NonNull
+ private String mTitle;
+
+ @NonNull
+ private String mMetricsCategory;
+
+ private boolean mIsPersonalProfile;
+
+ public NoAppsAvailableEmptyState(String title, String metricsCategory,
+ boolean isPersonalProfile) {
+ mTitle = title;
+ mMetricsCategory = metricsCategory;
+ mIsPersonalProfile = isPersonalProfile;
+ }
+
+ @Nullable
+ @Override
+ public String getTitle() {
+ return mTitle;
+ }
+
+ @Override
+ public void onEmptyStateShown() {
+ DevicePolicyEventLogger.createEvent(
+ DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED)
+ .setStrings(mMetricsCategory)
+ .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile)
+ .write();
+ }
+ }
+}
\ No newline at end of file
diff --git a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java
new file mode 100644
index 0000000..420d26c
--- /dev/null
+++ b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java
@@ -0,0 +1,137 @@
+/*
+ * 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.NonNull;
+import android.annotation.Nullable;
+import android.annotation.StringRes;
+import android.app.admin.DevicePolicyEventLogger;
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.os.UserHandle;
+
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider;
+
+/**
+ * Empty state provider that does not allow cross profile sharing, it will return a blocker
+ * in case if the profile of the current tab is not the same as the profile of the calling app.
+ */
+public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider {
+
+ private final UserHandle mPersonalProfileUserHandle;
+ private final EmptyState mNoWorkToPersonalEmptyState;
+ private final EmptyState mNoPersonalToWorkEmptyState;
+ private final CrossProfileIntentsChecker mCrossProfileIntentsChecker;
+ private final MyUserIdProvider mUserIdProvider;
+
+ public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle,
+ EmptyState noWorkToPersonalEmptyState,
+ EmptyState noPersonalToWorkEmptyState,
+ CrossProfileIntentsChecker crossProfileIntentsChecker,
+ MyUserIdProvider myUserIdProvider) {
+ mPersonalProfileUserHandle = personalUserHandle;
+ mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState;
+ mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState;
+ mCrossProfileIntentsChecker = crossProfileIntentsChecker;
+ mUserIdProvider = myUserIdProvider;
+ }
+
+ @Nullable
+ @Override
+ public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
+ boolean shouldShowBlocker =
+ mUserIdProvider.getMyUserId() != resolverListAdapter.getUserHandle().getIdentifier()
+ && !mCrossProfileIntentsChecker
+ .hasCrossProfileIntents(resolverListAdapter.getIntents(),
+ mUserIdProvider.getMyUserId(),
+ resolverListAdapter.getUserHandle().getIdentifier());
+
+ if (!shouldShowBlocker) {
+ return null;
+ }
+
+ if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) {
+ return mNoWorkToPersonalEmptyState;
+ } else {
+ return mNoPersonalToWorkEmptyState;
+ }
+ }
+
+
+ /**
+ * Empty state that gets strings from the device policy manager and tracks events into
+ * event logger of the device policy events.
+ */
+ public static class DevicePolicyBlockerEmptyState implements EmptyState {
+
+ @NonNull
+ private final Context mContext;
+ private final String mDevicePolicyStringTitleId;
+ @StringRes
+ private final int mDefaultTitleResource;
+ private final String mDevicePolicyStringSubtitleId;
+ @StringRes
+ private final int mDefaultSubtitleResource;
+ private final int mEventId;
+ @NonNull
+ private final String mEventCategory;
+
+ public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId,
+ @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId,
+ @StringRes int defaultSubtitleResource,
+ int devicePolicyEventId, String devicePolicyEventCategory) {
+ mContext = context;
+ mDevicePolicyStringTitleId = devicePolicyStringTitleId;
+ mDefaultTitleResource = defaultTitleResource;
+ mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId;
+ mDefaultSubtitleResource = defaultSubtitleResource;
+ mEventId = devicePolicyEventId;
+ mEventCategory = devicePolicyEventCategory;
+ }
+
+ @Nullable
+ @Override
+ public String getTitle() {
+ return mContext.getSystemService(DevicePolicyManager.class).getResources().getString(
+ mDevicePolicyStringTitleId,
+ () -> mContext.getString(mDefaultTitleResource));
+ }
+
+ @Nullable
+ @Override
+ public String getSubtitle() {
+ return mContext.getSystemService(DevicePolicyManager.class).getResources().getString(
+ mDevicePolicyStringSubtitleId,
+ () -> mContext.getString(mDefaultSubtitleResource));
+ }
+
+ @Override
+ public void onEmptyStateShown() {
+ DevicePolicyEventLogger.createEvent(mEventId)
+ .setStrings(mEventCategory)
+ .write();
+ }
+
+ @Override
+ public boolean shouldSkipDataRebuild() {
+ return true;
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java
index 453a6e8..5573e18 100644
--- a/java/src/com/android/intentresolver/ResolverActivity.java
+++ b/java/src/com/android/intentresolver/ResolverActivity.java
@@ -19,6 +19,9 @@
import static android.Manifest.permission.INTERACT_ACROSS_PROFILES;
import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL;
import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK;
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL;
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK;
+import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE;
import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB;
import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY;
import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED;
@@ -26,6 +29,8 @@
import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY;
import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
import static android.content.PermissionChecker.PID_UNKNOWN;
+import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL;
+import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK;
import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
import android.annotation.Nullable;
@@ -39,7 +44,6 @@
import android.app.VoiceInteractor.Prompt;
import android.app.admin.DevicePolicyEventLogger;
import android.app.admin.DevicePolicyManager;
-import android.compat.annotation.UnsupportedAppUsage;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
@@ -55,7 +59,9 @@
import android.content.res.Configuration;
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;
@@ -90,18 +96,25 @@
import android.widget.TextView;
import android.widget.Toast;
+import androidx.fragment.app.FragmentActivity;
+import androidx.viewpager.widget.ViewPager;
+
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CompositeEmptyStateProvider;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener;
import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile;
-import com.android.intentresolver.chooser.ChooserTargetInfo;
+import com.android.intentresolver.AbstractMultiProfilePagerAdapter.QuietModeManager;
+import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
-
+import com.android.intentresolver.widget.ResolverDrawerLayout;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.content.PackageMonitor;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto;
import com.android.internal.util.LatencyTracker;
-import com.android.internal.widget.ResolverDrawerLayout;
-import com.android.internal.widget.ViewPager;
import java.util.ArrayList;
import java.util.Arrays;
@@ -109,6 +122,7 @@
import java.util.List;
import java.util.Objects;
import java.util.Set;
+import java.util.function.Supplier;
/**
* This activity is displayed when the system attempts to start an Intent for
@@ -116,10 +130,9 @@
* which to go to. It is not normally used directly by application developers.
*/
@UiThread
-public class ResolverActivity extends Activity implements
+public class ResolverActivity extends FragmentActivity implements
ResolverListAdapter.ResolverListCommunicator {
- @UnsupportedAppUsage
public ResolverActivity() {
mIsIntentPicker = getClass().equals(ResolverActivity.class);
}
@@ -149,7 +162,6 @@
@VisibleForTesting
protected boolean mSupportsAlwaysUseOption;
protected ResolverDrawerLayout mResolverDrawerLayout;
- @UnsupportedAppUsage
protected PackageManager mPm;
protected int mLaunchedFromUid;
@@ -165,17 +177,12 @@
/** See {@link #setRetainInOnStop}. */
private boolean mRetainInOnStop;
- private static final String EXTRA_SHOW_FRAGMENT_ARGS = ":settings:show_fragment_args";
- private static final String EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key";
- private static final String OPEN_LINKS_COMPONENT_KEY = "app_link_state";
protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver";
protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser";
/** Tracks if we should ignore future broadcasts telling us the work profile is enabled */
private boolean mWorkProfileHasBeenEnabled = false;
- @VisibleForTesting
- public static boolean ENABLE_TABBED_VIEW = true;
private static final String TAB_TAG_PERSONAL = "personal";
private static final String TAB_TAG_WORK = "work";
@@ -185,6 +192,8 @@
@VisibleForTesting
protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter;
+ protected QuietModeManager mQuietModeManager;
+
// Intent extra for connected audio devices
public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device";
@@ -214,7 +223,14 @@
private BroadcastReceiver mWorkProfileStateReceiver;
private UserHandle mHeaderCreatorUser;
- private UserHandle mWorkProfileUserHandle;
+ private Supplier<UserHandle> mLazyWorkProfileUserHandle = () -> {
+ final UserHandle result = fetchWorkProfileUserProfile();
+ mLazyWorkProfileUserHandle = () -> result;
+ return result;
+ };
+
+ @Nullable
+ private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
protected final LatencyTracker mLatencyTracker = getLatencyTracker();
@@ -360,7 +376,6 @@
* Compatibility version for other bundled services that use this overload without
* a default title resource
*/
- @UnsupportedAppUsage
protected void onCreate(Bundle savedInstanceState, Intent intent,
CharSequence title, Intent[] initialIntents,
List<ResolveInfo> rList, boolean supportsAlwaysUseOption) {
@@ -374,6 +389,8 @@
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());
@@ -395,7 +412,6 @@
mDefaultTitleResId = defaultTitleRes;
mSupportsAlwaysUseOption = supportsAlwaysUseOption;
- mWorkProfileUserHandle = fetchWorkProfileUserProfile();
// The last argument of createResolverListAdapter is whether to do special handling
// of the last used choice to highlight it in the list. We need to always
@@ -474,6 +490,111 @@
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());
+
+ if (!shouldShowNoCrossProfileIntentsEmptyState) {
+ // Implementation that doesn't show any blockers
+ return new EmptyStateProvider() {};
+ }
+
+ final AbstractMultiProfilePagerAdapter.EmptyState
+ noWorkToPersonalEmptyState =
+ new DevicePolicyBlockerEmptyState(/* context= */ this,
+ /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
+ /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked,
+ /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_PERSONAL,
+ /* defaultSubtitleResource= */
+ R.string.resolver_cant_access_personal_apps_explanation,
+ /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL,
+ /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER);
+
+ final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState =
+ new DevicePolicyBlockerEmptyState(/* context= */ this,
+ /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
+ /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked,
+ /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_WORK,
+ /* defaultSubtitleResource= */
+ R.string.resolver_cant_access_work_apps_explanation,
+ /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK,
+ /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_RESOLVER);
+
+ return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(),
+ noWorkToPersonalEmptyState, noPersonalToWorkEmptyState,
+ createCrossProfileIntentsChecker(), createMyUserIdProvider());
+ }
+
+ protected EmptyStateProvider createEmptyStateProvider(
+ @Nullable UserHandle workProfileUserHandle) {
+ final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider();
+
+ final EmptyStateProvider workProfileOffEmptyStateProvider =
+ new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle,
+ mQuietModeManager,
+ /* onSwitchOnWorkSelectedListener= */
+ () -> { if (mOnSwitchOnWorkSelectedListener != null) {
+ mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
+ }},
+ getMetricsCategory());
+
+ final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider(
+ this,
+ workProfileUserHandle,
+ getPersonalProfileUserHandle(),
+ getMetricsCategory(),
+ createMyUserIdProvider()
+ );
+
+ // Return composite provider, the order matters (the higher, the more priority)
+ return new CompositeEmptyStateProvider(
+ blockerEmptyStateProvider,
+ workProfileOffEmptyStateProvider,
+ noAppsEmptyStateProvider
+ );
+ }
+
private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile(
Intent[] initialIntents,
List<ResolveInfo> rList, boolean filterLastUsed) {
@@ -484,13 +605,21 @@
rList,
filterLastUsed,
/* userHandle */ UserHandle.of(UserHandle.myUserId()));
+ QuietModeManager quietModeManager = createQuietModeManager();
return new ResolverMultiProfilePagerAdapter(
/* context */ this,
adapter,
- getPersonalProfileUserHandle(),
+ createEmptyStateProvider(/* workProfileUserHandle= */ null),
+ quietModeManager,
/* workProfileUserHandle= */ null);
}
+ private UserHandle getIntentUser() {
+ return getIntent().hasExtra(EXTRA_CALLING_USER)
+ ? getIntent().getParcelableExtra(EXTRA_CALLING_USER)
+ : getUser();
+ }
+
private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles(
Intent[] initialIntents,
List<ResolveInfo> rList,
@@ -499,9 +628,7 @@
// the intent resolver is started in the other profile. Since this is the only case when
// this happens, we check for it here and set the current profile's tab.
int selectedProfile = getCurrentProfile();
- UserHandle intentUser = getIntent().hasExtra(EXTRA_CALLING_USER)
- ? getIntent().getParcelableExtra(EXTRA_CALLING_USER)
- : getUser();
+ UserHandle intentUser = getIntentUser();
if (!getUser().equals(intentUser)) {
if (getPersonalProfileUserHandle().equals(intentUser)) {
selectedProfile = PROFILE_PERSONAL;
@@ -534,14 +661,15 @@
(filterLastUsed && UserHandle.myUserId()
== workProfileUserHandle.getIdentifier()),
/* userHandle */ workProfileUserHandle);
+ QuietModeManager quietModeManager = createQuietModeManager();
return new ResolverMultiProfilePagerAdapter(
/* context */ this,
personalAdapter,
workAdapter,
+ createEmptyStateProvider(getWorkProfileUserHandle()),
+ quietModeManager,
selectedProfile,
- getPersonalProfileUserHandle(),
- getWorkProfileUserHandle(),
- /* shouldShowNoCrossProfileIntentsEmptyState= */ getUser().equals(intentUser));
+ getWorkProfileUserHandle());
}
protected int appliedThemeResId() {
@@ -574,19 +702,25 @@
protected UserHandle getPersonalProfileUserHandle() {
return UserHandle.of(ActivityManager.getCurrentUser());
}
- protected @Nullable UserHandle getWorkProfileUserHandle() {
- return mWorkProfileUserHandle;
+
+ @Nullable
+ protected UserHandle getWorkProfileUserHandle() {
+ return mLazyWorkProfileUserHandle.get();
}
- protected @Nullable UserHandle fetchWorkProfileUserProfile() {
- mWorkProfileUserHandle = null;
+ @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()) {
- mWorkProfileUserHandle = userInfo.getUserHandle();
+ result = userInfo.getUserHandle();
}
}
- return mWorkProfileUserHandle;
+ return result;
}
private boolean hasWorkProfile() {
@@ -594,7 +728,7 @@
}
protected boolean shouldShowTabs() {
- return hasWorkProfile() && ENABLE_TABBED_VIEW;
+ return hasWorkProfile();
}
protected void onProfileClick(View v) {
@@ -726,7 +860,6 @@
}
}
- @Override // SelectableTargetInfoCommunicator ResolverListCommunicator
public Intent getTargetIntent() {
return mIntents.isEmpty() ? null : mIntents.get(0);
}
@@ -848,9 +981,9 @@
}
mRegistered = true;
}
- if (shouldShowTabs() && mMultiProfilePagerAdapter.isWaitingToEnableWorkProfile()) {
- if (mMultiProfilePagerAdapter.isQuietModeEnabled(getWorkProfileUserHandle())) {
- mMultiProfilePagerAdapter.markWorkProfileEnabledBroadcastReceived();
+ if (shouldShowTabs() && mQuietModeManager.isWaitingToEnableWorkProfile()) {
+ if (mQuietModeManager.isQuietModeEnabled(getWorkProfileUserHandle())) {
+ mQuietModeManager.markWorkProfileEnabledBroadcastReceived();
}
}
mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();
@@ -1375,7 +1508,7 @@
.createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED)
.setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle()))
.setStrings(getMetricsCategory(),
- cti instanceof ChooserTargetInfo ? "direct_share" : "other_target")
+ cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target")
.write();
}
@@ -1407,8 +1540,16 @@
Intent startIntent = getIntent();
boolean isAudioCaptureDevice =
startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false);
- return new ResolverListAdapter(context, payloadIntents, initialIntents, rList,
- filterLastUsed, createListController(userHandle), this,
+ return new ResolverListAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ createListController(userHandle),
+ userHandle,
+ getTargetIntent(),
+ this,
isAudioCaptureDevice);
}
@@ -1472,17 +1613,25 @@
setContentView(mLayoutId);
DisplayResolveInfo sameProfileResolveInfo =
- mMultiProfilePagerAdapter.getActiveListAdapter().mDisplayList.get(0);
+ mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo();
boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK;
- ResolverListAdapter inactiveAdapter = mMultiProfilePagerAdapter.getInactiveListAdapter();
- DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.mDisplayList.get(0);
+ final ResolverListAdapter inactiveAdapter =
+ mMultiProfilePagerAdapter.getInactiveListAdapter();
+ final DisplayResolveInfo otherProfileResolveInfo =
+ inactiveAdapter.getFirstDisplayResolveInfo();
// Load the icon asynchronously
ImageView icon = findViewById(com.android.internal.R.id.icon);
- ResolverListAdapter.LoadIconTask iconTask = inactiveAdapter.new LoadIconTask(
- otherProfileResolveInfo, new ResolverListAdapter.ViewHolder(icon));
- iconTask.execute();
+ inactiveAdapter.new LoadIconTask(otherProfileResolveInfo) {
+ @Override
+ protected void onPostExecute(Drawable drawable) {
+ if (!isDestroyed()) {
+ otherProfileResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable);
+ new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo);
+ }
+ }
+ }.execute();
((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText(
getResources().getString(
@@ -1521,31 +1670,29 @@
|| mMultiProfilePagerAdapter.getInactiveListAdapter() == null) {
return false;
}
- List<DisplayResolveInfo> sameProfileList =
- mMultiProfilePagerAdapter.getActiveListAdapter().mDisplayList;
- List<DisplayResolveInfo> otherProfileList =
- mMultiProfilePagerAdapter.getInactiveListAdapter().mDisplayList;
+ ResolverListAdapter sameProfileAdapter =
+ mMultiProfilePagerAdapter.getActiveListAdapter();
+ ResolverListAdapter otherProfileAdapter =
+ mMultiProfilePagerAdapter.getInactiveListAdapter();
- if (sameProfileList.isEmpty()) {
+ if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) {
Log.d(TAG, "No targets in the current profile");
return false;
}
- if (otherProfileList.size() != 1) {
- Log.d(TAG, "Found " + otherProfileList.size() + " resolvers in the other profile");
+ if (otherProfileAdapter.getDisplayResolveInfoCount() != 1) {
+ Log.d(TAG, "Other-profile count: " + otherProfileAdapter.getDisplayResolveInfoCount());
return false;
}
- if (otherProfileList.get(0).getResolveInfo().handleAllWebDataURI) {
+ if (otherProfileAdapter.allResolveInfosHandleAllWebDataUri()) {
Log.d(TAG, "Other profile is a web browser");
return false;
}
- for (DisplayResolveInfo info : sameProfileList) {
- if (!info.getResolveInfo().handleAllWebDataURI) {
- Log.d(TAG, "Non-browser found in this profile");
- return false;
- }
+ if (!sameProfileAdapter.allResolveInfosHandleAllWebDataUri()) {
+ Log.d(TAG, "Non-browser found in this profile");
+ return false;
}
return true;
@@ -1800,13 +1947,12 @@
onHorizontalSwipeStateChanged(state);
}
});
- mMultiProfilePagerAdapter.setOnSwitchOnWorkSelectedListener(
- () -> {
- final View workTab = tabHost.getTabWidget().getChildAt(1);
- workTab.setFocusable(true);
- workTab.setFocusableInTouchMode(true);
- workTab.requestFocus();
- });
+ mOnSwitchOnWorkSelectedListener = () -> {
+ final View workTab = tabHost.getTabWidget().getChildAt(1);
+ workTab.setFocusable(true);
+ workTab.setFocusableInTouchMode(true);
+ workTab.requestFocus();
+ };
}
private String getPersonalTabLabel() {
@@ -2067,7 +2213,7 @@
public void onHandlePackagesChanged(ResolverListAdapter listAdapter) {
if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) {
if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle())
- && mMultiProfilePagerAdapter.isWaitingToEnableWorkProfile()) {
+ && 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
@@ -2119,7 +2265,7 @@
}
mWorkProfileHasBeenEnabled = true;
- mMultiProfilePagerAdapter.markWorkProfileEnabledBroadcastReceived();
+ mQuietModeManager.markWorkProfileEnabledBroadcastReceived();
} else {
// Must be an UNAVAILABLE broadcast, so we watch for the next availability
mWorkProfileHasBeenEnabled = false;
@@ -2135,13 +2281,11 @@
};
}
- @VisibleForTesting
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;
- private boolean mFixedAtTop;
public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) {
this.name = name;
@@ -2190,14 +2334,6 @@
public void setPinned(boolean pinned) {
mPinned = pinned;
}
-
- public boolean isFixedAtTop() {
- return mFixedAtTop;
- }
-
- public void setFixedAtTop(boolean isFixedAtTop) {
- mFixedAtTop = isFixedAtTop;
- }
}
class ItemClickListener implements AdapterView.OnItemClickListener,
@@ -2254,8 +2390,9 @@
}
- static final boolean isSpecificUriMatch(int match) {
- match = match&IntentFilter.MATCH_CATEGORY_MASK;
+ /** Determine whether a given match result is considered "specific" in our application. */
+ public static final boolean isSpecificUriMatch(int match) {
+ match = (match & IntentFilter.MATCH_CATEGORY_MASK);
return match >= IntentFilter.MATCH_CATEGORY_HOST
&& match <= IntentFilter.MATCH_CATEGORY_PATH;
}
diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java
index 898d8c8..eecb914 100644
--- a/java/src/com/android/intentresolver/ResolverListAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverListAdapter.java
@@ -26,15 +26,11 @@
import android.content.Intent;
import android.content.PermissionChecker;
import android.content.pm.ActivityInfo;
-import android.content.pm.ApplicationInfo;
import android.content.pm.LabeledIntent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
-import android.content.res.Resources;
-import android.graphics.Bitmap;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
-import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.RemoteException;
@@ -54,44 +50,62 @@
import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
-
import com.android.internal.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+
import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
public class ResolverListAdapter extends BaseAdapter {
private static final String TAG = "ResolverListAdapter";
+ @Nullable // TODO: other model for lazy computation? Or just precompute?
+ private static ColorMatrixColorFilter sSuspendedMatrixColorFilter;
+
+ protected final Context mContext;
+ protected final LayoutInflater mInflater;
+ protected final ResolverListCommunicator mResolverListCommunicator;
+ protected final ResolverListController mResolverListController;
+ protected final TargetPresentationGetter.Factory mPresentationFactory;
+
private final List<Intent> mIntents;
private final Intent[] mInitialIntents;
private final List<ResolveInfo> mBaseResolveList;
private final PackageManager mPm;
- protected final Context mContext;
- private static ColorMatrixColorFilter sSuspendedMatrixColorFilter;
private final int mIconDpi;
- protected ResolveInfo mLastChosen;
+ private final boolean mIsAudioCaptureDevice;
+ private final UserHandle mUserHandle;
+ private final Intent mTargetIntent;
+
+ private final Map<DisplayResolveInfo, LoadIconTask> mIconLoaders = new HashMap<>();
+ private final Map<DisplayResolveInfo, LoadLabelTask> mLabelLoaders = new HashMap<>();
+
+ private ResolveInfo mLastChosen;
private DisplayResolveInfo mOtherProfile;
- ResolverListController mResolverListController;
private int mPlaceholderCount;
- protected final LayoutInflater mInflater;
-
// This one is the list that the Adapter will actually present.
- List<DisplayResolveInfo> mDisplayList;
+ private List<DisplayResolveInfo> mDisplayList;
private List<ResolvedComponentInfo> mUnfilteredResolveList;
private int mLastChosenPosition = -1;
private boolean mFilterLastUsed;
- final ResolverListCommunicator mResolverListCommunicator;
private Runnable mPostListReadyRunnable;
- private final boolean mIsAudioCaptureDevice;
private boolean mIsTabLoaded;
- public ResolverListAdapter(Context context, List<Intent> payloadIntents,
- Intent[] initialIntents, List<ResolveInfo> rList,
+ public ResolverListAdapter(
+ Context context,
+ List<Intent> payloadIntents,
+ Intent[] initialIntents,
+ List<ResolveInfo> rList,
boolean filterLastUsed,
ResolverListController resolverListController,
+ UserHandle userHandle,
+ Intent targetIntent,
ResolverListCommunicator resolverListCommunicator,
boolean isAudioCaptureDevice) {
mContext = context;
@@ -103,10 +117,21 @@
mDisplayList = new ArrayList<>();
mFilterLastUsed = filterLastUsed;
mResolverListController = resolverListController;
+ mUserHandle = userHandle;
+ mTargetIntent = targetIntent;
mResolverListCommunicator = resolverListCommunicator;
mIsAudioCaptureDevice = isAudioCaptureDevice;
final ActivityManager am = (ActivityManager) mContext.getSystemService(ACTIVITY_SERVICE);
mIconDpi = am.getLauncherLargeIconDensity();
+ mPresentationFactory = new TargetPresentationGetter.Factory(mContext, mIconDpi);
+ }
+
+ public final DisplayResolveInfo getFirstDisplayResolveInfo() {
+ return mDisplayList.get(0);
+ }
+
+ public final ImmutableList<DisplayResolveInfo> getTargetsInCurrentDisplayList() {
+ return ImmutableList.copyOf(mDisplayList);
}
public void handlePackagesChanged() {
@@ -258,7 +283,7 @@
if (mBaseResolveList != null) {
List<ResolvedComponentInfo> currentResolveList = new ArrayList<>();
mResolverListController.addResolveListDedupe(currentResolveList,
- mResolverListCommunicator.getTargetIntent(),
+ mTargetIntent,
mBaseResolveList);
return currentResolveList;
} else {
@@ -334,7 +359,12 @@
if (otherProfileInfo != null) {
mOtherProfile = makeOtherProfileDisplayResolveInfo(
- mContext, otherProfileInfo, mPm, mResolverListCommunicator, mIconDpi);
+ mContext,
+ otherProfileInfo,
+ mPm,
+ mTargetIntent,
+ mResolverListCommunicator,
+ mIconDpi);
} else {
mOtherProfile = null;
try {
@@ -441,8 +471,13 @@
ri.icon = 0;
}
- addResolveInfo(new DisplayResolveInfo(ii, ri,
- ri.loadLabel(mPm), null, ii, makePresentationGetter(ri)));
+ addResolveInfo(DisplayResolveInfo.newDisplayResolveInfo(
+ ii,
+ ri,
+ ri.loadLabel(mPm),
+ null,
+ ii,
+ mPresentationFactory.makePresentationGetter(ri)));
}
}
@@ -490,10 +525,12 @@
final Intent replaceIntent =
mResolverListCommunicator.getReplacementIntent(add.activityInfo, intent);
final Intent defaultIntent = mResolverListCommunicator.getReplacementIntent(
- add.activityInfo, mResolverListCommunicator.getTargetIntent());
- final DisplayResolveInfo
- dri = new DisplayResolveInfo(intent, add,
- replaceIntent != null ? replaceIntent : defaultIntent, makePresentationGetter(add));
+ add.activityInfo, mTargetIntent);
+ final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
+ intent,
+ add,
+ (replaceIntent != null) ? replaceIntent : defaultIntent,
+ mPresentationFactory.makePresentationGetter(add));
dri.setPinned(rci.isPinned());
if (rci.isPinned()) {
Log.i(TAG, "Pinned item: " + rci.name);
@@ -597,11 +634,15 @@
return position;
}
- public int getDisplayResolveInfoCount() {
+ public final int getDisplayResolveInfoCount() {
return mDisplayList.size();
}
- public DisplayResolveInfo getDisplayResolveInfo(int index) {
+ public final boolean allResolveInfosHandleAllWebDataUri() {
+ return mDisplayList.stream().allMatch(t -> t.getResolveInfo().handleAllWebDataURI);
+ }
+
+ public final DisplayResolveInfo getDisplayResolveInfo(int index) {
// Used to query services. We only query services for primary targets, not alternates.
return mDisplayList.get(index);
}
@@ -634,28 +675,49 @@
protected void onBindView(View view, TargetInfo info, int position) {
final ViewHolder holder = (ViewHolder) view.getTag();
if (info == null) {
- holder.icon.setImageDrawable(
- mContext.getDrawable(R.drawable.resolver_icon_placeholder));
+ holder.icon.setImageDrawable(loadIconPlaceholder());
+ holder.bindLabel("", "", false);
return;
}
- if (info instanceof DisplayResolveInfo
- && !((DisplayResolveInfo) info).hasDisplayLabel()) {
- getLoadLabelTask((DisplayResolveInfo) info, holder).execute();
- } else {
- holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel());
- }
-
- if (info instanceof DisplayResolveInfo
- && !((DisplayResolveInfo) info).hasDisplayIcon()) {
- new LoadIconTask((DisplayResolveInfo) info, holder).execute();
- } else {
+ if (info.isDisplayResolveInfo()) {
+ DisplayResolveInfo dri = (DisplayResolveInfo) info;
+ if (dri.hasDisplayLabel()) {
+ holder.bindLabel(
+ dri.getDisplayLabel(),
+ dri.getExtendedInfo(),
+ alwaysShowSubLabel());
+ } else {
+ holder.bindLabel("", "", false);
+ loadLabel(dri);
+ }
holder.bindIcon(info);
+ if (!dri.hasDisplayIcon()) {
+ loadIcon(dri);
+ }
}
}
- protected LoadLabelTask getLoadLabelTask(DisplayResolveInfo info, ViewHolder holder) {
- return new LoadLabelTask(info, holder);
+ protected final void loadIcon(DisplayResolveInfo info) {
+ LoadIconTask task = mIconLoaders.get(info);
+ if (task == null) {
+ task = new LoadIconTask(info);
+ mIconLoaders.put(info, task);
+ task.execute();
+ }
+ }
+
+ private void loadLabel(DisplayResolveInfo info) {
+ LoadLabelTask task = mLabelLoaders.get(info);
+ if (task == null) {
+ task = createLoadLabelTask(info);
+ mLabelLoaders.put(info, task);
+ task.execute();
+ }
+ }
+
+ protected LoadLabelTask createLoadLabelTask(DisplayResolveInfo info) {
+ return new LoadLabelTask(info);
}
public void onDestroy() {
@@ -666,6 +728,16 @@
if (mResolverListController != null) {
mResolverListController.destroy();
}
+ cancelTasks(mIconLoaders.values());
+ cancelTasks(mLabelLoaders.values());
+ mIconLoaders.clear();
+ mLabelLoaders.clear();
+ }
+
+ private <T extends AsyncTask> void cancelTasks(Collection<T> tasks) {
+ for (T task: tasks) {
+ task.cancel(false);
+ }
}
private static ColorMatrixColorFilter getSuspendedColorMatrix() {
@@ -691,17 +763,13 @@
return sSuspendedMatrixColorFilter;
}
- ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo ai) {
- return new ActivityInfoPresentationGetter(mContext, mIconDpi, ai);
- }
-
- ResolveInfoPresentationGetter makePresentationGetter(ResolveInfo ri) {
- return new ResolveInfoPresentationGetter(mContext, mIconDpi, ri);
- }
-
Drawable loadIconForResolveInfo(ResolveInfo ri) {
// Load icons based on the current process. If in work profile icons should be badged.
- return makePresentationGetter(ri).getIcon(getUserHandle());
+ return mPresentationFactory.makePresentationGetter(ri).getIcon(getUserHandle());
+ }
+
+ protected final Drawable loadIconPlaceholder() {
+ return mContext.getDrawable(R.drawable.resolver_icon_placeholder);
}
void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) {
@@ -710,7 +778,15 @@
new AsyncTask<Void, Void, Drawable>() {
@Override
protected Drawable doInBackground(Void... params) {
- return loadIconForResolveInfo(iconInfo.getResolveInfo());
+ Drawable drawable;
+ try {
+ drawable = loadIconForResolveInfo(iconInfo.getResolveInfo());
+ } catch (Exception e) {
+ ComponentName componentName = iconInfo.getResolvedComponentName();
+ Log.e(TAG, "Failed to load app icon for " + componentName, e);
+ drawable = loadIconPlaceholder();
+ }
+ return drawable;
}
@Override
@@ -721,9 +797,8 @@
}
}
- @VisibleForTesting
public UserHandle getUserHandle() {
- return mResolverListController.getUserHandle();
+ return mUserHandle;
}
protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) {
@@ -779,6 +854,7 @@
Context context,
ResolvedComponentInfo resolvedComponentInfo,
PackageManager pm,
+ Intent targetIntent,
ResolverListCommunicator resolverListCommunicator,
int iconDpi) {
ResolveInfo resolveInfo = resolvedComponentInfo.getResolveInfoAt(0);
@@ -787,13 +863,13 @@
resolveInfo.activityInfo,
resolvedComponentInfo.getIntentAt(0));
Intent replacementIntent = resolverListCommunicator.getReplacementIntent(
- resolveInfo.activityInfo,
- resolverListCommunicator.getTargetIntent());
+ resolveInfo.activityInfo, targetIntent);
- ResolveInfoPresentationGetter presentationGetter =
- new ResolveInfoPresentationGetter(context, iconDpi, resolveInfo);
+ TargetPresentationGetter presentationGetter =
+ new TargetPresentationGetter.Factory(context, iconDpi)
+ .makePresentationGetter(resolveInfo);
- return new DisplayResolveInfo(
+ return DisplayResolveInfo.newDisplayResolveInfo(
resolvedComponentInfo.getIntentAt(0),
resolveInfo,
resolveInfo.loadLabel(pm),
@@ -829,13 +905,12 @@
*/
default boolean shouldGetOnlyDefaultActivities() { return true; };
- Intent getTargetIntent();
-
void onHandlePackagesChanged(ResolverListAdapter listAdapter);
}
/**
- * A view holder.
+ * A view holder keeps a reference to a list view and provides functionality for managing its
+ * state.
*/
@VisibleForTesting
public static class ViewHolder {
@@ -877,7 +952,7 @@
}
public void bindIcon(TargetInfo info) {
- icon.setImageDrawable(info.getDisplayIcon(itemView.getContext()));
+ icon.setImageDrawable(info.getDisplayIconHolder().getDisplayIcon());
if (info.isSuspended()) {
icon.setColorFilter(getSuspendedColorMatrix());
} else {
@@ -888,17 +963,15 @@
protected class LoadLabelTask extends AsyncTask<Void, Void, CharSequence[]> {
private final DisplayResolveInfo mDisplayResolveInfo;
- private final ViewHolder mHolder;
- protected LoadLabelTask(DisplayResolveInfo dri, ViewHolder holder) {
+ protected LoadLabelTask(DisplayResolveInfo dri) {
mDisplayResolveInfo = dri;
- mHolder = holder;
}
@Override
protected CharSequence[] doInBackground(Void... voids) {
- ResolveInfoPresentationGetter pg =
- makePresentationGetter(mDisplayResolveInfo.getResolveInfo());
+ TargetPresentationGetter pg = mPresentationFactory.makePresentationGetter(
+ mDisplayResolveInfo.getResolveInfo());
if (mIsAudioCaptureDevice) {
// This is an audio capture device, so check record permissions
@@ -930,26 +1003,33 @@
@Override
protected void onPostExecute(CharSequence[] result) {
+ if (mDisplayResolveInfo.hasDisplayLabel()) {
+ return;
+ }
mDisplayResolveInfo.setDisplayLabel(result[0]);
mDisplayResolveInfo.setExtendedInfo(result[1]);
- mHolder.bindLabel(result[0], result[1], alwaysShowSubLabel());
+ notifyDataSetChanged();
}
}
class LoadIconTask extends AsyncTask<Void, Void, Drawable> {
protected final DisplayResolveInfo mDisplayResolveInfo;
private final ResolveInfo mResolveInfo;
- private ViewHolder mHolder;
- LoadIconTask(DisplayResolveInfo dri, ViewHolder holder) {
+ LoadIconTask(DisplayResolveInfo dri) {
mDisplayResolveInfo = dri;
mResolveInfo = dri.getResolveInfo();
- mHolder = holder;
}
@Override
protected Drawable doInBackground(Void... params) {
- return loadIconForResolveInfo(mResolveInfo);
+ try {
+ return loadIconForResolveInfo(mResolveInfo);
+ } catch (Exception e) {
+ ComponentName componentName = mDisplayResolveInfo.getResolvedComponentName();
+ Log.e(TAG, "Failed to load app icon for " + componentName, e);
+ return loadIconPlaceholder();
+ }
}
@Override
@@ -957,207 +1037,9 @@
if (getOtherProfile() == mDisplayResolveInfo) {
mResolverListCommunicator.updateProfileViewButton();
} else if (!mDisplayResolveInfo.hasDisplayIcon()) {
- mDisplayResolveInfo.setDisplayIcon(d);
- mHolder.bindIcon(mDisplayResolveInfo);
- // Notify in case view is already bound to resolve the race conditions on
- // low end devices
+ mDisplayResolveInfo.getDisplayIconHolder().setDisplayIcon(d);
notifyDataSetChanged();
}
}
-
- public void setViewHolder(ViewHolder holder) {
- mHolder = holder;
- mHolder.bindIcon(mDisplayResolveInfo);
- }
- }
-
- /**
- * Loads the icon and label for the provided ResolveInfo.
- */
- @VisibleForTesting
- public static class ResolveInfoPresentationGetter extends ActivityInfoPresentationGetter {
- private final ResolveInfo mRi;
- public ResolveInfoPresentationGetter(Context ctx, int iconDpi, ResolveInfo ri) {
- super(ctx, iconDpi, ri.activityInfo);
- mRi = ri;
- }
-
- @Override
- Drawable getIconSubstituteInternal() {
- Drawable dr = null;
- try {
- // Do not use ResolveInfo#getIconResource() as it defaults to the app
- if (mRi.resolvePackageName != null && mRi.icon != 0) {
- dr = loadIconFromResource(
- mPm.getResourcesForApplication(mRi.resolvePackageName), mRi.icon);
- }
- } catch (PackageManager.NameNotFoundException e) {
- Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but "
- + "couldn't find resources for package", e);
- }
-
- // Fall back to ActivityInfo if no icon is found via ResolveInfo
- if (dr == null) dr = super.getIconSubstituteInternal();
-
- return dr;
- }
-
- @Override
- String getAppSubLabelInternal() {
- // Will default to app name if no intent filter or activity label set, make sure to
- // check if subLabel matches label before final display
- return mRi.loadLabel(mPm).toString();
- }
-
- @Override
- String getAppLabelForSubstitutePermission() {
- // Will default to app name if no activity label set
- return mRi.getComponentInfo().loadLabel(mPm).toString();
- }
- }
-
- /**
- * Loads the icon and label for the provided ActivityInfo.
- */
- @VisibleForTesting
- public static class ActivityInfoPresentationGetter extends
- TargetPresentationGetter {
- private final ActivityInfo mActivityInfo;
- public ActivityInfoPresentationGetter(Context ctx, int iconDpi,
- ActivityInfo activityInfo) {
- super(ctx, iconDpi, activityInfo.applicationInfo);
- mActivityInfo = activityInfo;
- }
-
- @Override
- Drawable getIconSubstituteInternal() {
- Drawable dr = null;
- try {
- // Do not use ActivityInfo#getIconResource() as it defaults to the app
- if (mActivityInfo.icon != 0) {
- dr = loadIconFromResource(
- mPm.getResourcesForApplication(mActivityInfo.applicationInfo),
- mActivityInfo.icon);
- }
- } catch (PackageManager.NameNotFoundException e) {
- Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but "
- + "couldn't find resources for package", e);
- }
-
- return dr;
- }
-
- @Override
- String getAppSubLabelInternal() {
- // Will default to app name if no activity label set, make sure to check if subLabel
- // matches label before final display
- return (String) mActivityInfo.loadLabel(mPm);
- }
-
- @Override
- String getAppLabelForSubstitutePermission() {
- return getAppSubLabelInternal();
- }
- }
-
- /**
- * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application
- * icon and label over any IntentFilter or Activity icon to increase user understanding, with an
- * exception for applications that hold the right permission. Always attempts to use available
- * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses
- * Strings to strip creative formatting.
- */
- private abstract static class TargetPresentationGetter {
- @Nullable abstract Drawable getIconSubstituteInternal();
- @Nullable abstract String getAppSubLabelInternal();
- @Nullable abstract String getAppLabelForSubstitutePermission();
-
- private Context mCtx;
- private final int mIconDpi;
- private final boolean mHasSubstitutePermission;
- private final ApplicationInfo mAi;
-
- protected PackageManager mPm;
-
- TargetPresentationGetter(Context ctx, int iconDpi, ApplicationInfo ai) {
- mCtx = ctx;
- mPm = ctx.getPackageManager();
- mAi = ai;
- mIconDpi = iconDpi;
- mHasSubstitutePermission = PackageManager.PERMISSION_GRANTED == mPm.checkPermission(
- android.Manifest.permission.SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON,
- mAi.packageName);
- }
-
- public Drawable getIcon(UserHandle userHandle) {
- return new BitmapDrawable(mCtx.getResources(), getIconBitmap(userHandle));
- }
-
- public Bitmap getIconBitmap(@Nullable UserHandle userHandle) {
- Drawable dr = null;
- if (mHasSubstitutePermission) {
- dr = getIconSubstituteInternal();
- }
-
- if (dr == null) {
- try {
- if (mAi.icon != 0) {
- dr = loadIconFromResource(mPm.getResourcesForApplication(mAi), mAi.icon);
- }
- } catch (PackageManager.NameNotFoundException ignore) {
- }
- }
-
- // Fall back to ApplicationInfo#loadIcon if nothing has been loaded
- if (dr == null) {
- dr = mAi.loadIcon(mPm);
- }
-
- SimpleIconFactory sif = SimpleIconFactory.obtain(mCtx);
- Bitmap icon = sif.createUserBadgedIconBitmap(dr, userHandle);
- sif.recycle();
-
- return icon;
- }
-
- public String getLabel() {
- String label = null;
- // Apps with the substitute permission will always show the activity label as the
- // app label if provided
- if (mHasSubstitutePermission) {
- label = getAppLabelForSubstitutePermission();
- }
-
- if (label == null) {
- label = (String) mAi.loadLabel(mPm);
- }
-
- return label;
- }
-
- public String getSubLabel() {
- // Apps with the substitute permission will always show the resolve info label as the
- // sublabel if provided
- if (mHasSubstitutePermission){
- String appSubLabel = getAppSubLabelInternal();
- // Use the resolve info label as sublabel if it is set
- if(!TextUtils.isEmpty(appSubLabel)
- && !TextUtils.equals(appSubLabel, getLabel())){
- return appSubLabel;
- }
- return null;
- }
- return getAppSubLabelInternal();
- }
-
- protected String loadLabelFromResource(Resources res, int resId) {
- return res.getString(resId);
- }
-
- @Nullable
- protected Drawable loadIconFromResource(Resources res, int resId) {
- return res.getDrawableForDensity(resId, mIconDpi);
- }
-
}
}
diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java
index ff616ce..bfffe0d 100644
--- a/java/src/com/android/intentresolver/ResolverListController.java
+++ b/java/src/com/android/intentresolver/ResolverListController.java
@@ -32,7 +32,8 @@
import android.util.Log;
import com.android.intentresolver.chooser.DisplayResolveInfo;
-
+import com.android.intentresolver.model.AbstractResolverComparator;
+import com.android.intentresolver.model.ResolverRankerServiceResolverComparator;
import com.android.internal.annotations.VisibleForTesting;
import java.util.ArrayList;
@@ -187,7 +188,6 @@
final ResolverActivity.ResolvedComponentInfo rci =
new ResolverActivity.ResolvedComponentInfo(name, intent, newInfo);
rci.setPinned(isComponentPinned(name));
- rci.setFixedAtTop(isFixedAtTop(name));
into.add(rci);
}
}
@@ -202,14 +202,6 @@
return false;
}
- /**
- * Whether this component is fixed at top in the ranked apps list. Always false for Resolver;
- * overridden in Chooser.
- */
- public boolean isFixedAtTop(ComponentName name) {
- return false;
- }
-
// Filter out any activities that the launched uid does not have permission for.
// To preserve the inputList, optionally will return the original list if any modification has
// been made.
@@ -274,19 +266,6 @@
return listToReturn;
}
- private class ComputeCallback implements AbstractResolverComparator.AfterCompute {
-
- private CountDownLatch mFinishComputeSignal;
-
- public ComputeCallback(CountDownLatch finishComputeSignal) {
- mFinishComputeSignal = finishComputeSignal;
- }
-
- public void afterCompute () {
- mFinishComputeSignal.countDown();
- }
- }
-
private void compute(List<ResolverActivity.ResolvedComponentInfo> inputList)
throws InterruptedException {
if (mResolverComparator == null) {
@@ -294,8 +273,7 @@
return;
}
final CountDownLatch finishComputeSignal = new CountDownLatch(1);
- ComputeCallback callback = new ComputeCallback(finishComputeSignal);
- mResolverComparator.setCallBack(callback);
+ mResolverComparator.setCallBack(() -> finishComputeSignal.countDown());
mResolverComparator.compute(inputList);
finishComputeSignal.await();
isComputed = true;
diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
index 56d326c..65de940 100644
--- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
+++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java
@@ -16,264 +16,99 @@
package com.android.intentresolver;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS;
-import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE;
-
-import android.annotation.Nullable;
-import android.app.admin.DevicePolicyManager;
import android.content.Context;
import android.os.UserHandle;
import android.view.LayoutInflater;
-import android.view.View;
import android.view.ViewGroup;
import android.widget.ListView;
+import androidx.viewpager.widget.PagerAdapter;
+
import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.widget.PagerAdapter;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.Optional;
+import java.util.function.Supplier;
/**
* A {@link PagerAdapter} which describes the work and personal profile intent resolver screens.
*/
@VisibleForTesting
-public class ResolverMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter {
+public class ResolverMultiProfilePagerAdapter extends
+ GenericMultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> {
+ private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier;
- private final ResolverProfileDescriptor[] mItems;
- private final boolean mShouldShowNoCrossProfileIntentsEmptyState;
- private boolean mUseLayoutWithDefault;
-
- ResolverMultiProfilePagerAdapter(Context context,
+ ResolverMultiProfilePagerAdapter(
+ Context context,
ResolverListAdapter adapter,
- UserHandle personalProfileUserHandle,
+ EmptyStateProvider emptyStateProvider,
+ QuietModeManager quietModeManager,
UserHandle workProfileUserHandle) {
- super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle);
- mItems = new ResolverProfileDescriptor[] {
- createProfileDescriptor(adapter)
- };
- mShouldShowNoCrossProfileIntentsEmptyState = true;
+ this(
+ context,
+ ImmutableList.of(adapter),
+ emptyStateProvider,
+ quietModeManager,
+ /* defaultProfile= */ 0,
+ workProfileUserHandle,
+ new BottomPaddingOverrideSupplier());
}
ResolverMultiProfilePagerAdapter(Context context,
ResolverListAdapter personalAdapter,
ResolverListAdapter workAdapter,
+ EmptyStateProvider emptyStateProvider,
+ QuietModeManager quietModeManager,
@Profile int defaultProfile,
- UserHandle personalProfileUserHandle,
+ UserHandle workProfileUserHandle) {
+ this(
+ context,
+ ImmutableList.of(personalAdapter, workAdapter),
+ emptyStateProvider,
+ quietModeManager,
+ defaultProfile,
+ workProfileUserHandle,
+ new BottomPaddingOverrideSupplier());
+ }
+
+ private ResolverMultiProfilePagerAdapter(
+ Context context,
+ ImmutableList<ResolverListAdapter> listAdapters,
+ EmptyStateProvider emptyStateProvider,
+ QuietModeManager quietModeManager,
+ @Profile int defaultProfile,
UserHandle workProfileUserHandle,
- boolean shouldShowNoCrossProfileIntentsEmptyState) {
- super(context, /* currentPage */ defaultProfile, personalProfileUserHandle,
- workProfileUserHandle);
- mItems = new ResolverProfileDescriptor[] {
- createProfileDescriptor(personalAdapter),
- createProfileDescriptor(workAdapter)
- };
- mShouldShowNoCrossProfileIntentsEmptyState = shouldShowNoCrossProfileIntentsEmptyState;
+ BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) {
+ super(
+ context,
+ listAdapter -> listAdapter,
+ (listView, bindAdapter) -> listView.setAdapter(bindAdapter),
+ listAdapters,
+ emptyStateProvider,
+ quietModeManager,
+ defaultProfile,
+ workProfileUserHandle,
+ () -> (ViewGroup) LayoutInflater.from(context).inflate(
+ R.layout.resolver_list_per_profile, null, false),
+ bottomPaddingOverrideSupplier);
+ mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier;
}
- private ResolverProfileDescriptor createProfileDescriptor(
- ResolverListAdapter adapter) {
- final LayoutInflater inflater = LayoutInflater.from(getContext());
- final ViewGroup rootView =
- (ViewGroup) inflater.inflate(R.layout.resolver_list_per_profile, null, false);
- return new ResolverProfileDescriptor(rootView, adapter);
+ public void setUseLayoutWithDefault(boolean useLayoutWithDefault) {
+ mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault);
}
- ListView getListViewForIndex(int index) {
- return getItem(index).listView;
- }
+ private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> {
+ private boolean mUseLayoutWithDefault;
- @Override
- ResolverProfileDescriptor getItem(int pageIndex) {
- return mItems[pageIndex];
- }
-
- @Override
- int getItemCount() {
- return mItems.length;
- }
-
- @Override
- void setupListAdapter(int pageIndex) {
- final ListView listView = getItem(pageIndex).listView;
- listView.setAdapter(getItem(pageIndex).resolverListAdapter);
- }
-
- @Override
- @VisibleForTesting
- public ResolverListAdapter getAdapterForIndex(int pageIndex) {
- return mItems[pageIndex].resolverListAdapter;
- }
-
- @Override
- public ViewGroup instantiateItem(ViewGroup container, int position) {
- setupListAdapter(position);
- return super.instantiateItem(container, position);
- }
-
- @Override
- @Nullable
- ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle) {
- if (getActiveListAdapter().getUserHandle().equals(userHandle)) {
- return getActiveListAdapter();
- } else if (getInactiveListAdapter() != null
- && getInactiveListAdapter().getUserHandle().equals(userHandle)) {
- return getInactiveListAdapter();
+ public void setUseLayoutWithDefault(boolean useLayoutWithDefault) {
+ mUseLayoutWithDefault = useLayoutWithDefault;
}
- return null;
- }
- @Override
- @VisibleForTesting
- public ResolverListAdapter getActiveListAdapter() {
- return getAdapterForIndex(getCurrentPage());
- }
-
- @Override
- @VisibleForTesting
- public ResolverListAdapter getInactiveListAdapter() {
- if (getCount() == 1) {
- return null;
- }
- return getAdapterForIndex(1 - getCurrentPage());
- }
-
- @Override
- public ResolverListAdapter getPersonalListAdapter() {
- return getAdapterForIndex(PROFILE_PERSONAL);
- }
-
- @Override
- @Nullable
- public ResolverListAdapter getWorkListAdapter() {
- return getAdapterForIndex(PROFILE_WORK);
- }
-
- @Override
- ResolverListAdapter getCurrentRootAdapter() {
- return getActiveListAdapter();
- }
-
- @Override
- ListView getActiveAdapterView() {
- return getListViewForIndex(getCurrentPage());
- }
-
- @Override
- @Nullable
- ViewGroup getInactiveAdapterView() {
- if (getCount() == 1) {
- return null;
- }
- return getListViewForIndex(1 - getCurrentPage());
- }
-
- @Override
- String getMetricsCategory() {
- return ResolverActivity.METRICS_CATEGORY_RESOLVER;
- }
-
- @Override
- boolean allowShowNoCrossProfileIntentsEmptyState() {
- return mShouldShowNoCrossProfileIntentsEmptyState;
- }
-
- @Override
- protected void showWorkProfileOffEmptyState(ResolverListAdapter activeListAdapter,
- View.OnClickListener listener) {
- showEmptyState(activeListAdapter,
- getWorkAppPausedTitle(),
- /* subtitle = */ null,
- listener);
- }
-
- @Override
- protected void showNoPersonalToWorkIntentsEmptyState(ResolverListAdapter activeListAdapter) {
- showEmptyState(activeListAdapter,
- getCrossProfileBlockedTitle(),
- getCantAccessWorkMessage());
- }
-
- @Override
- protected void showNoWorkToPersonalIntentsEmptyState(ResolverListAdapter activeListAdapter) {
- showEmptyState(activeListAdapter,
- getCrossProfileBlockedTitle(),
- getCantAccessPersonalMessage());
- }
-
- @Override
- protected void showNoPersonalAppsAvailableEmptyState(ResolverListAdapter listAdapter) {
- showEmptyState(listAdapter,
- getNoPersonalAppsAvailableMessage(),
- /* subtitle = */ null);
- }
-
- @Override
- protected void showNoWorkAppsAvailableEmptyState(ResolverListAdapter listAdapter) {
- showEmptyState(listAdapter,
- getNoWorkAppsAvailableMessage(),
- /* subtitle= */ null);
- }
-
- private String getWorkAppPausedTitle() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_WORK_PAUSED_TITLE,
- () -> getContext().getString(R.string.resolver_turn_on_work_apps));
- }
-
- private String getCrossProfileBlockedTitle() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_CROSS_PROFILE_BLOCKED_TITLE,
- () -> getContext().getString(R.string.resolver_cross_profile_blocked));
- }
-
- private String getCantAccessWorkMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_CANT_ACCESS_WORK,
- () -> getContext().getString(
- R.string.resolver_cant_access_work_apps_explanation));
- }
-
- private String getCantAccessPersonalMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_CANT_ACCESS_PERSONAL,
- () -> getContext().getString(
- R.string.resolver_cant_access_personal_apps_explanation));
- }
-
- private String getNoWorkAppsAvailableMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_NO_WORK_APPS,
- () -> getContext().getString(
- R.string.resolver_no_work_apps_available));
- }
-
- private String getNoPersonalAppsAvailableMessage() {
- return getContext().getSystemService(DevicePolicyManager.class).getResources().getString(
- RESOLVER_NO_PERSONAL_APPS,
- () -> getContext().getString(
- R.string.resolver_no_personal_apps_available));
- }
-
- void setUseLayoutWithDefault(boolean useLayoutWithDefault) {
- mUseLayoutWithDefault = useLayoutWithDefault;
- }
-
- @Override
- protected void setupContainerPadding(View container) {
- int bottom = mUseLayoutWithDefault ? container.getPaddingBottom() : 0;
- container.setPadding(container.getPaddingLeft(), container.getPaddingTop(),
- container.getPaddingRight(), bottom);
- }
-
- class ResolverProfileDescriptor extends ProfileDescriptor {
- private ResolverListAdapter resolverListAdapter;
- final ListView listView;
- ResolverProfileDescriptor(ViewGroup rootView, ResolverListAdapter adapter) {
- super(rootView);
- resolverListAdapter = adapter;
- listView = rootView.findViewById(com.android.internal.R.id.resolver_list);
+ @Override
+ public Optional<Integer> get() {
+ return mUseLayoutWithDefault ? Optional.empty() : Optional.of(0);
}
}
}
diff --git a/java/src/com/android/intentresolver/ResolverViewPager.java b/java/src/com/android/intentresolver/ResolverViewPager.java
index 1c23452..0804a2b 100644
--- a/java/src/com/android/intentresolver/ResolverViewPager.java
+++ b/java/src/com/android/intentresolver/ResolverViewPager.java
@@ -21,7 +21,7 @@
import android.view.MotionEvent;
import android.view.View;
-import com.android.internal.widget.ViewPager;
+import androidx.viewpager.widget.ViewPager;
/**
* A {@link ViewPager} which wraps around its tallest child's height.
@@ -41,15 +41,6 @@
super(context, attrs);
}
- public ResolverViewPager(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- }
-
- public ResolverViewPager(Context context, AttributeSet attrs,
- int defStyleAttr, int defStyleRes) {
- super(context, attrs, defStyleAttr, defStyleRes);
- }
-
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
new file mode 100644
index 0000000..645b939
--- /dev/null
+++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java
@@ -0,0 +1,193 @@
+/*
+ * 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.prediction.AppTarget;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ShortcutInfo;
+import android.service.chooser.ChooserTarget;
+import android.util.Log;
+
+import com.android.intentresolver.chooser.DisplayResolveInfo;
+import com.android.intentresolver.chooser.SelectableTargetInfo;
+import com.android.intentresolver.chooser.TargetInfo;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+class ShortcutSelectionLogic {
+ private static final String TAG = "ShortcutSelectionLogic";
+ private static final boolean DEBUG = false;
+ private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f;
+ private static final int MAX_CHOOSER_TARGETS_PER_APP = 2;
+
+ private final int mMaxShortcutTargetsPerApp;
+ private final boolean mApplySharingAppLimits;
+
+ // Descending order
+ private final Comparator<ChooserTarget> mBaseTargetComparator =
+ (lhs, rhs) -> Float.compare(rhs.getScore(), lhs.getScore());
+
+ ShortcutSelectionLogic(
+ int maxShortcutTargetsPerApp,
+ boolean applySharingAppLimits) {
+ mMaxShortcutTargetsPerApp = maxShortcutTargetsPerApp;
+ mApplySharingAppLimits = applySharingAppLimits;
+ }
+
+ /**
+ * Evaluate targets for inclusion in the direct share area. May not be included
+ * if score is too low.
+ */
+ public boolean addServiceResults(
+ @Nullable DisplayResolveInfo origTarget,
+ float origTargetScore,
+ List<ChooserTarget> targets,
+ boolean isShortcutResult,
+ Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos,
+ Map<ChooserTarget, AppTarget> directShareToAppTargets,
+ Context userContext,
+ Intent targetIntent,
+ Intent referrerFillInIntent,
+ int maxRankedTargets,
+ List<TargetInfo> serviceTargets) {
+ if (DEBUG) {
+ Log.d(TAG, "addServiceResults "
+ + (origTarget == null ? null : origTarget.getResolvedComponentName()) + ", "
+ + targets.size()
+ + " targets");
+ }
+ if (targets.size() == 0) {
+ return false;
+ }
+ Collections.sort(targets, mBaseTargetComparator);
+ final int maxTargets = isShortcutResult ? mMaxShortcutTargetsPerApp
+ : MAX_CHOOSER_TARGETS_PER_APP;
+ final int targetsLimit = mApplySharingAppLimits ? Math.min(targets.size(), maxTargets)
+ : targets.size();
+ float lastScore = 0;
+ boolean shouldNotify = false;
+ for (int i = 0, count = targetsLimit; i < count; i++) {
+ final ChooserTarget target = targets.get(i);
+ float targetScore = target.getScore();
+ if (mApplySharingAppLimits) {
+ targetScore *= origTargetScore;
+ if (i > 0 && targetScore >= lastScore) {
+ // Apply a decay so that the top app can't crowd out everything else.
+ // This incents ChooserTargetServices to define what's truly better.
+ targetScore = lastScore * 0.95f;
+ }
+ }
+ ShortcutInfo shortcutInfo = isShortcutResult ? directShareToShortcutInfos.get(target)
+ : null;
+ if ((shortcutInfo != null) && shortcutInfo.isPinned()) {
+ targetScore += PINNED_SHORTCUT_TARGET_SCORE_BOOST;
+ }
+ ResolveInfo backupResolveInfo;
+ Intent resolvedIntent;
+ if (origTarget == null) {
+ resolvedIntent = createResolvedIntentForCallerTarget(target, targetIntent);
+ backupResolveInfo = userContext.getPackageManager()
+ .resolveActivity(
+ resolvedIntent,
+ PackageManager.ResolveInfoFlags.of(PackageManager.GET_META_DATA));
+ } else {
+ resolvedIntent = origTarget.getResolvedIntent();
+ backupResolveInfo = null;
+ }
+ boolean isInserted = insertServiceTarget(
+ SelectableTargetInfo.newSelectableTargetInfo(
+ origTarget,
+ backupResolveInfo,
+ resolvedIntent,
+ target,
+ targetScore,
+ shortcutInfo,
+ directShareToAppTargets.get(target),
+ referrerFillInIntent),
+ maxRankedTargets,
+ serviceTargets);
+
+ shouldNotify |= isInserted;
+
+ if (DEBUG) {
+ Log.d(TAG, " => " + target + " score=" + targetScore
+ + " base=" + target.getScore()
+ + " lastScore=" + lastScore
+ + " baseScore=" + origTargetScore
+ + " applyAppLimit=" + mApplySharingAppLimits);
+ }
+
+ lastScore = targetScore;
+ }
+
+ return shouldNotify;
+ }
+
+ /**
+ * Creates a resolved intent for a caller-specified target.
+ * @param target, a caller-specified target.
+ * @param targetIntent, a target intent for the Chooser (see {@link Intent#EXTRA_INTENT}).
+ */
+ private static Intent createResolvedIntentForCallerTarget(
+ ChooserTarget target, Intent targetIntent) {
+ final Intent resolvedIntent = new Intent(targetIntent);
+ resolvedIntent.setComponent(target.getComponentName());
+ resolvedIntent.putExtras(target.getIntentExtras());
+ return resolvedIntent;
+ }
+
+ private boolean insertServiceTarget(
+ TargetInfo chooserTargetInfo,
+ int maxRankedTargets,
+ List<TargetInfo> serviceTargets) {
+
+ // Check for duplicates and abort if found
+ for (TargetInfo otherTargetInfo : serviceTargets) {
+ if (chooserTargetInfo.isSimilar(otherTargetInfo)) {
+ return false;
+ }
+ }
+
+ int currentSize = serviceTargets.size();
+ final float newScore = chooserTargetInfo.getModifiedScore();
+ for (int i = 0; i < Math.min(currentSize, maxRankedTargets);
+ i++) {
+ final TargetInfo serviceTarget = serviceTargets.get(i);
+ if (serviceTarget == null) {
+ serviceTargets.set(i, chooserTargetInfo);
+ return true;
+ } else if (newScore > serviceTarget.getModifiedScore()) {
+ serviceTargets.add(i, chooserTargetInfo);
+ return true;
+ }
+ }
+
+ if (currentSize < maxRankedTargets) {
+ serviceTargets.add(chooserTargetInfo);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java
index b05b4f6..ec5179a 100644
--- a/java/src/com/android/intentresolver/SimpleIconFactory.java
+++ b/java/src/com/android/intentresolver/SimpleIconFactory.java
@@ -50,6 +50,8 @@
import android.util.Pools.SynchronizedPool;
import android.util.TypedValue;
+import com.android.internal.annotations.VisibleForTesting;
+
import org.xmlpull.v1.XmlPullParser;
import java.nio.ByteBuffer;
@@ -67,6 +69,7 @@
private static final SynchronizedPool<SimpleIconFactory> sPool =
new SynchronizedPool<>(Runtime.getRuntime().availableProcessors());
+ private static boolean sPoolEnabled = true;
private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE;
private static final float BLUR_FACTOR = 1.5f / 48;
@@ -90,7 +93,7 @@
*/
@Deprecated
public static SimpleIconFactory obtain(Context ctx) {
- SimpleIconFactory instance = sPool.acquire();
+ SimpleIconFactory instance = sPoolEnabled ? sPool.acquire() : null;
if (instance == null) {
final ActivityManager am = (ActivityManager) ctx.getSystemService(ACTIVITY_SERVICE);
final int iconDpi = (am == null) ? 0 : am.getLauncherLargeIconDensity();
@@ -104,6 +107,17 @@
return instance;
}
+ /**
+ * Enables or disables SimpleIconFactory objects pooling. It is enabled in production, you
+ * could use this method in tests and disable the pooling to make the icon rendering more
+ * deterministic because some sizing parameters will not be cached. Please ensure that you
+ * reset this value back after finishing the test.
+ */
+ @VisibleForTesting
+ public static void setPoolEnabled(boolean poolEnabled) {
+ sPoolEnabled = poolEnabled;
+ }
+
private static int getAttrDimFromContext(Context ctx, @AttrRes int attrId, String errorMsg) {
final Resources res = ctx.getResources();
TypedValue outVal = new TypedValue();
diff --git a/java/src/com/android/intentresolver/TargetPresentationGetter.java b/java/src/com/android/intentresolver/TargetPresentationGetter.java
new file mode 100644
index 0000000..f8b3656
--- /dev/null
+++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java
@@ -0,0 +1,267 @@
+/*
+ * 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.content.Context;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.UserHandle;
+import android.text.TextUtils;
+import android.util.Log;
+
+/**
+ * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application icon
+ * and label over any IntentFilter or Activity icon to increase user understanding, with an
+ * exception for applications that hold the right permission. Always attempts to use available
+ * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses
+ * Strings to strip creative formatting.
+ *
+ * Use one of the {@link TargetPresentationGetter#Factory} methods to create an instance of the
+ * appropriate concrete type.
+ *
+ * TODO: once this component (and its tests) are merged, it should be possible to refactor and
+ * vastly simplify by precomputing conditional logic at initialization.
+ */
+public abstract class TargetPresentationGetter {
+ private static final String TAG = "ResolverListAdapter";
+
+ /** Helper to build appropriate type-specific {@link TargetPresentationGetter} instances. */
+ public static class Factory {
+ private final Context mContext;
+ private final int mIconDpi;
+
+ public Factory(Context context, int iconDpi) {
+ mContext = context;
+ mIconDpi = iconDpi;
+ }
+
+ /** Make a {@link TargetPresentationGetter} for an {@link ActivityInfo}. */
+ public TargetPresentationGetter makePresentationGetter(ActivityInfo activityInfo) {
+ return new ActivityInfoPresentationGetter(mContext, mIconDpi, activityInfo);
+ }
+
+ /** Make a {@link TargetPresentationGetter} for a {@link ResolveInfo}. */
+ public TargetPresentationGetter makePresentationGetter(ResolveInfo resolveInfo) {
+ return new ResolveInfoPresentationGetter(mContext, mIconDpi, resolveInfo);
+ }
+ }
+
+ @Nullable
+ protected abstract Drawable getIconSubstituteInternal();
+
+ @Nullable
+ protected abstract String getAppSubLabelInternal();
+
+ @Nullable
+ protected abstract String getAppLabelForSubstitutePermission();
+
+ private Context mContext;
+ private final int mIconDpi;
+ private final boolean mHasSubstitutePermission;
+ private final ApplicationInfo mAppInfo;
+
+ protected PackageManager mPm;
+
+ /**
+ * Retrieve the image that should be displayed as the icon when this target is presented to the
+ * specified {@code userHandle}.
+ */
+ public Drawable getIcon(UserHandle userHandle) {
+ return new BitmapDrawable(mContext.getResources(), getIconBitmap(userHandle));
+ }
+
+ /**
+ * Retrieve the image that should be displayed as the icon when this target is presented to the
+ * specified {@code userHandle}.
+ */
+ public Bitmap getIconBitmap(@Nullable UserHandle userHandle) {
+ Drawable drawable = null;
+ if (mHasSubstitutePermission) {
+ drawable = getIconSubstituteInternal();
+ }
+
+ if (drawable == null) {
+ try {
+ if (mAppInfo.icon != 0) {
+ drawable = loadIconFromResource(
+ mPm.getResourcesForApplication(mAppInfo), mAppInfo.icon);
+ }
+ } catch (PackageManager.NameNotFoundException ignore) { }
+ }
+
+ // Fall back to ApplicationInfo#loadIcon if nothing has been loaded
+ if (drawable == null) {
+ drawable = mAppInfo.loadIcon(mPm);
+ }
+
+ SimpleIconFactory iconFactory = SimpleIconFactory.obtain(mContext);
+ Bitmap icon = iconFactory.createUserBadgedIconBitmap(drawable, userHandle);
+ iconFactory.recycle();
+
+ return icon;
+ }
+
+ /** Get the label to display for the target. */
+ public String getLabel() {
+ String label = null;
+ // Apps with the substitute permission will always show the activity label as the app label
+ // if provided.
+ if (mHasSubstitutePermission) {
+ label = getAppLabelForSubstitutePermission();
+ }
+
+ if (label == null) {
+ label = (String) mAppInfo.loadLabel(mPm);
+ }
+
+ return label;
+ }
+
+ /**
+ * Get the sublabel to display for the target. Clients are responsible for deduping their
+ * presentation if this returns the same value as {@link #getLabel()}.
+ * TODO: this class should take responsibility for that deduping internally so it's an
+ * authoritative record of exactly the content that should be presented.
+ */
+ public String getSubLabel() {
+ // Apps with the substitute permission will always show the resolve info label as the
+ // sublabel if provided
+ if (mHasSubstitutePermission) {
+ String appSubLabel = getAppSubLabelInternal();
+ // Use the resolve info label as sublabel if it is set
+ if (!TextUtils.isEmpty(appSubLabel) && !TextUtils.equals(appSubLabel, getLabel())) {
+ return appSubLabel;
+ }
+ return null;
+ }
+ return getAppSubLabelInternal();
+ }
+
+ protected String loadLabelFromResource(Resources res, int resId) {
+ return res.getString(resId);
+ }
+
+ @Nullable
+ protected Drawable loadIconFromResource(Resources res, int resId) {
+ return res.getDrawableForDensity(resId, mIconDpi);
+ }
+
+ private TargetPresentationGetter(Context context, int iconDpi, ApplicationInfo appInfo) {
+ mContext = context;
+ mPm = context.getPackageManager();
+ mAppInfo = appInfo;
+ mIconDpi = iconDpi;
+ mHasSubstitutePermission = (PackageManager.PERMISSION_GRANTED == mPm.checkPermission(
+ android.Manifest.permission.SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON,
+ mAppInfo.packageName));
+ }
+
+ /** Loads the icon and label for the provided ResolveInfo. */
+ private static class ResolveInfoPresentationGetter extends ActivityInfoPresentationGetter {
+ private final ResolveInfo mResolveInfo;
+
+ ResolveInfoPresentationGetter(
+ Context context, int iconDpi, ResolveInfo resolveInfo) {
+ super(context, iconDpi, resolveInfo.activityInfo);
+ mResolveInfo = resolveInfo;
+ }
+
+ @Override
+ protected Drawable getIconSubstituteInternal() {
+ Drawable drawable = null;
+ try {
+ // Do not use ResolveInfo#getIconResource() as it defaults to the app
+ if (mResolveInfo.resolvePackageName != null && mResolveInfo.icon != 0) {
+ drawable = loadIconFromResource(
+ mPm.getResourcesForApplication(mResolveInfo.resolvePackageName),
+ mResolveInfo.icon);
+ }
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but "
+ + "couldn't find resources for package", e);
+ }
+
+ // Fall back to ActivityInfo if no icon is found via ResolveInfo
+ if (drawable == null) {
+ drawable = super.getIconSubstituteInternal();
+ }
+
+ return drawable;
+ }
+
+ @Override
+ protected String getAppSubLabelInternal() {
+ // Will default to app name if no intent filter or activity label set, make sure to
+ // check if subLabel matches label before final display
+ return mResolveInfo.loadLabel(mPm).toString();
+ }
+
+ @Override
+ protected String getAppLabelForSubstitutePermission() {
+ // Will default to app name if no activity label set
+ return mResolveInfo.getComponentInfo().loadLabel(mPm).toString();
+ }
+ }
+
+ /** Loads the icon and label for the provided {@link ActivityInfo}. */
+ private static class ActivityInfoPresentationGetter extends TargetPresentationGetter {
+ private final ActivityInfo mActivityInfo;
+
+ ActivityInfoPresentationGetter(
+ Context context, int iconDpi, ActivityInfo activityInfo) {
+ super(context, iconDpi, activityInfo.applicationInfo);
+ mActivityInfo = activityInfo;
+ }
+
+ @Override
+ protected Drawable getIconSubstituteInternal() {
+ Drawable drawable = null;
+ try {
+ // Do not use ActivityInfo#getIconResource() as it defaults to the app
+ if (mActivityInfo.icon != 0) {
+ drawable = loadIconFromResource(
+ mPm.getResourcesForApplication(mActivityInfo.applicationInfo),
+ mActivityInfo.icon);
+ }
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but "
+ + "couldn't find resources for package", e);
+ }
+
+ return drawable;
+ }
+
+ @Override
+ protected String getAppSubLabelInternal() {
+ // Will default to app name if no activity label set, make sure to check if subLabel
+ // matches label before final display
+ return (String) mActivityInfo.loadLabel(mPm);
+ }
+
+ @Override
+ protected String getAppLabelForSubstitutePermission() {
+ return getAppSubLabelInternal();
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
new file mode 100644
index 0000000..b7c8990
--- /dev/null
+++ b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java
@@ -0,0 +1,114 @@
+/*
+ * 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 android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.admin.DevicePolicyEventLogger;
+import android.app.admin.DevicePolicyManager;
+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;
+
+/**
+ * Chooser/ResolverActivity empty state provider that returns empty state which is shown when
+ * work profile is paused and we need to show a button to enable it.
+ */
+public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider {
+
+ private final UserHandle mWorkProfileUserHandle;
+ private final QuietModeManager mQuietModeManager;
+ private final String mMetricsCategory;
+ private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener;
+ private final Context mContext;
+
+ public WorkProfilePausedEmptyStateProvider(@NonNull Context context,
+ @Nullable UserHandle workProfileUserHandle,
+ @NonNull QuietModeManager quietModeManager,
+ @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener,
+ @NonNull String metricsCategory) {
+ mContext = context;
+ mWorkProfileUserHandle = workProfileUserHandle;
+ mQuietModeManager = quietModeManager;
+ mMetricsCategory = metricsCategory;
+ mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener;
+ }
+
+ @Nullable
+ @Override
+ public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) {
+ if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle)
+ || !mQuietModeManager.isQuietModeEnabled(mWorkProfileUserHandle)
+ || resolverListAdapter.getCount() == 0) {
+ return null;
+ }
+
+ final String title = mContext.getSystemService(DevicePolicyManager.class)
+ .getResources().getString(RESOLVER_WORK_PAUSED_TITLE,
+ () -> mContext.getString(R.string.resolver_turn_on_work_apps));
+
+ return new WorkProfileOffEmptyState(title, (tab) -> {
+ tab.showSpinner();
+ if (mOnSwitchOnWorkSelectedListener != null) {
+ mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected();
+ }
+ mQuietModeManager.requestQuietModeEnabled(false, mWorkProfileUserHandle);
+ }, mMetricsCategory);
+ }
+
+ public static class WorkProfileOffEmptyState implements EmptyState {
+
+ private final String mTitle;
+ private final ClickListener mOnClick;
+ private final String mMetricsCategory;
+
+ public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick,
+ @NonNull String metricsCategory) {
+ mTitle = title;
+ mOnClick = onClick;
+ mMetricsCategory = metricsCategory;
+ }
+
+ @Nullable
+ @Override
+ public String getTitle() {
+ return mTitle;
+ }
+
+ @Nullable
+ @Override
+ public ClickListener getButtonClickListener() {
+ return mOnClick;
+ }
+
+ @Override
+ public void onEmptyStateShown() {
+ DevicePolicyEventLogger
+ .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED)
+ .setStrings(mMetricsCategory)
+ .write();
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java
index 1c76307..8b9bfb3 100644
--- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java
@@ -16,38 +16,27 @@
package com.android.intentresolver.chooser;
-import android.service.chooser.ChooserTarget;
-import android.text.TextUtils;
+import java.util.ArrayList;
+import java.util.Arrays;
/**
* A TargetInfo for Direct Share. Includes a {@link ChooserTarget} representing the
* Direct Share deep link into an application.
*/
-public interface ChooserTargetInfo extends TargetInfo {
- float getModifiedScore();
+public abstract class ChooserTargetInfo implements TargetInfo {
- ChooserTarget getChooserTarget();
+ @Override
+ public final boolean isChooserTargetInfo() {
+ return true;
+ }
- /**
- * Do not label as 'equals', since this doesn't quite work
- * as intended with java 8.
- */
- default boolean isSimilar(ChooserTargetInfo other) {
- if (other == null) return false;
-
- ChooserTarget ct1 = getChooserTarget();
- ChooserTarget ct2 = other.getChooserTarget();
-
- // If either is null, there is not enough info to make an informed decision
- // about equality, so just exit
- if (ct1 == null || ct2 == null) return false;
-
- if (ct1.getComponentName().equals(ct2.getComponentName())
- && TextUtils.equals(getDisplayLabel(), other.getDisplayLabel())
- && TextUtils.equals(getExtendedInfo(), other.getExtendedInfo())) {
- return true;
+ @Override
+ public ArrayList<DisplayResolveInfo> getAllDisplayTargets() {
+ // TODO: consider making this the default behavior for all `TargetInfo` implementations
+ // (if it's reasonable for `DisplayResolveInfo.getDisplayResolveInfo()` to return `this`).
+ if (getDisplayResolveInfo() == null) {
+ return new ArrayList<>();
}
-
- return false;
+ return new ArrayList<>(Arrays.asList(getDisplayResolveInfo()));
}
}
diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
index e7ffe3c..db5ae0b 100644
--- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
+++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java
@@ -20,84 +20,122 @@
import android.annotation.Nullable;
import android.app.Activity;
import android.content.ComponentName;
-import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.pm.ResolveInfo;
-import android.graphics.drawable.Drawable;
import android.os.Bundle;
-import android.os.Parcel;
-import android.os.Parcelable;
import android.os.UserHandle;
import com.android.intentresolver.ResolverActivity;
-import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter;
+import com.android.intentresolver.TargetPresentationGetter;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
/**
* A TargetInfo plus additional information needed to render it (such as icon and label) and
* resolve it to an activity.
*/
-public class DisplayResolveInfo implements TargetInfo, Parcelable {
+public class DisplayResolveInfo implements TargetInfo {
private final ResolveInfo mResolveInfo;
private CharSequence mDisplayLabel;
- private Drawable mDisplayIcon;
private CharSequence mExtendedInfo;
private final Intent mResolvedIntent;
private final List<Intent> mSourceIntents = new ArrayList<>();
- private boolean mIsSuspended;
- private ResolveInfoPresentationGetter mResolveInfoPresentationGetter;
+ private final boolean mIsSuspended;
+ private TargetPresentationGetter mPresentationGetter;
private boolean mPinned = false;
+ private final IconHolder mDisplayIconHolder = new SettableIconHolder();
- public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, Intent pOrigIntent,
- ResolveInfoPresentationGetter resolveInfoPresentationGetter) {
- this(originalIntent, pri, null /*mDisplayLabel*/, null /*mExtendedInfo*/, pOrigIntent,
- resolveInfoPresentationGetter);
+ /** Create a new {@code DisplayResolveInfo} instance. */
+ public static DisplayResolveInfo newDisplayResolveInfo(
+ Intent originalIntent,
+ ResolveInfo resolveInfo,
+ @NonNull Intent resolvedIntent,
+ @Nullable TargetPresentationGetter presentationGetter) {
+ return newDisplayResolveInfo(
+ originalIntent,
+ resolveInfo,
+ /* displayLabel=*/ null,
+ /* extendedInfo=*/ null,
+ resolvedIntent,
+ presentationGetter);
}
- public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, CharSequence pLabel,
- CharSequence pInfo, @NonNull Intent resolvedIntent,
- @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) {
+ /** Create a new {@code DisplayResolveInfo} instance. */
+ public static DisplayResolveInfo newDisplayResolveInfo(
+ Intent originalIntent,
+ ResolveInfo resolveInfo,
+ CharSequence displayLabel,
+ CharSequence extendedInfo,
+ @NonNull Intent resolvedIntent,
+ @Nullable TargetPresentationGetter presentationGetter) {
+ return new DisplayResolveInfo(
+ originalIntent,
+ resolveInfo,
+ displayLabel,
+ extendedInfo,
+ resolvedIntent,
+ presentationGetter);
+ }
+
+ private DisplayResolveInfo(
+ Intent originalIntent,
+ ResolveInfo resolveInfo,
+ CharSequence displayLabel,
+ CharSequence extendedInfo,
+ @NonNull Intent resolvedIntent,
+ @Nullable TargetPresentationGetter presentationGetter) {
mSourceIntents.add(originalIntent);
- mResolveInfo = pri;
- mDisplayLabel = pLabel;
- mExtendedInfo = pInfo;
- mResolveInfoPresentationGetter = resolveInfoPresentationGetter;
+ mResolveInfo = resolveInfo;
+ mDisplayLabel = displayLabel;
+ mExtendedInfo = extendedInfo;
+ mPresentationGetter = presentationGetter;
+
+ 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);
- final ActivityInfo ai = mResolveInfo.activityInfo;
intent.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name));
-
- mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0;
-
mResolvedIntent = intent;
}
- private DisplayResolveInfo(DisplayResolveInfo other, Intent fillInIntent, int flags,
- ResolveInfoPresentationGetter resolveInfoPresentationGetter) {
+ private DisplayResolveInfo(
+ DisplayResolveInfo other,
+ Intent fillInIntent,
+ int flags,
+ TargetPresentationGetter presentationGetter) {
mSourceIntents.addAll(other.getAllSourceIntents());
mResolveInfo = other.mResolveInfo;
+ mIsSuspended = other.mIsSuspended;
mDisplayLabel = other.mDisplayLabel;
- mDisplayIcon = other.mDisplayIcon;
mExtendedInfo = other.mExtendedInfo;
mResolvedIntent = new Intent(other.mResolvedIntent);
mResolvedIntent.fillIn(fillInIntent, flags);
- mResolveInfoPresentationGetter = resolveInfoPresentationGetter;
+ mPresentationGetter = presentationGetter;
+
+ mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon());
}
- DisplayResolveInfo(DisplayResolveInfo other) {
+ protected DisplayResolveInfo(DisplayResolveInfo other) {
mSourceIntents.addAll(other.getAllSourceIntents());
mResolveInfo = other.mResolveInfo;
+ mIsSuspended = other.mIsSuspended;
mDisplayLabel = other.mDisplayLabel;
- mDisplayIcon = other.mDisplayIcon;
mExtendedInfo = other.mExtendedInfo;
mResolvedIntent = other.mResolvedIntent;
- mResolveInfoPresentationGetter = other.mResolveInfoPresentationGetter;
+ mPresentationGetter = other.mPresentationGetter;
+
+ mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon());
+ }
+
+ @Override
+ public final boolean isDisplayResolveInfo() {
+ return true;
}
public ResolveInfo getResolveInfo() {
@@ -105,9 +143,9 @@
}
public CharSequence getDisplayLabel() {
- if (mDisplayLabel == null && mResolveInfoPresentationGetter != null) {
- mDisplayLabel = mResolveInfoPresentationGetter.getLabel();
- mExtendedInfo = mResolveInfoPresentationGetter.getSubLabel();
+ if (mDisplayLabel == null && mPresentationGetter != null) {
+ mDisplayLabel = mPresentationGetter.getLabel();
+ mExtendedInfo = mPresentationGetter.getSubLabel();
}
return mDisplayLabel;
}
@@ -124,13 +162,14 @@
mExtendedInfo = extendedInfo;
}
- public Drawable getDisplayIcon(Context context) {
- return mDisplayIcon;
+ @Override
+ public IconHolder getDisplayIconHolder() {
+ return mDisplayIconHolder;
}
@Override
public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) {
- return new DisplayResolveInfo(this, fillInIntent, flags, mResolveInfoPresentationGetter);
+ return new DisplayResolveInfo(this, fillInIntent, flags, mPresentationGetter);
}
@Override
@@ -138,18 +177,15 @@
return mSourceIntents;
}
+ @Override
+ public ArrayList<DisplayResolveInfo> getAllDisplayTargets() {
+ return new ArrayList<>(Arrays.asList(this));
+ }
+
public void addAlternateSourceIntent(Intent alt) {
mSourceIntents.add(alt);
}
- public void setDisplayIcon(Drawable icon) {
- mDisplayIcon = icon;
- }
-
- public boolean hasDisplayIcon() {
- return mDisplayIcon != null;
- }
-
public CharSequence getExtendedInfo() {
return mExtendedInfo;
}
@@ -172,14 +208,14 @@
@Override
public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
- prepareIntentForCrossProfileLaunch(mResolvedIntent, userId);
+ TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, userId);
activity.startActivityAsCaller(mResolvedIntent, options, false, userId);
return true;
}
@Override
public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
- prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier());
+ TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier());
activity.startActivityAsUser(mResolvedIntent, options, user);
return false;
}
@@ -196,48 +232,4 @@
public void setPinned(boolean pinned) {
mPinned = pinned;
}
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel dest, int flags) {
- dest.writeCharSequence(mDisplayLabel);
- dest.writeCharSequence(mExtendedInfo);
- dest.writeParcelable(mResolvedIntent, 0);
- dest.writeTypedList(mSourceIntents);
- dest.writeBoolean(mIsSuspended);
- dest.writeBoolean(mPinned);
- dest.writeParcelable(mResolveInfo, 0);
- }
-
- public static final Parcelable.Creator<DisplayResolveInfo> CREATOR =
- new Parcelable.Creator<DisplayResolveInfo>() {
- public DisplayResolveInfo createFromParcel(Parcel in) {
- return new DisplayResolveInfo(in);
- }
-
- public DisplayResolveInfo[] newArray(int size) {
- return new DisplayResolveInfo[size];
- }
- };
-
- private static void prepareIntentForCrossProfileLaunch(Intent intent, int targetUserId) {
- final int currentUserId = UserHandle.myUserId();
- if (targetUserId != currentUserId) {
- intent.fixUris(currentUserId);
- }
- }
-
- private DisplayResolveInfo(Parcel in) {
- mDisplayLabel = in.readCharSequence();
- mExtendedInfo = in.readCharSequence();
- mResolvedIntent = in.readParcelable(null /* ClassLoader */, android.content.Intent.class);
- in.readTypedList(mSourceIntents, Intent.CREATOR);
- mIsSuspended = in.readBoolean();
- mPinned = in.readBoolean();
- mResolveInfo = in.readParcelable(null /* ClassLoader */, android.content.pm.ResolveInfo.class);
- }
}
diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
index 5133d99..29f00a3 100644
--- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
+++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java
@@ -23,6 +23,7 @@
import com.android.intentresolver.ResolverActivity;
import java.util.ArrayList;
+import java.util.List;
/**
* Represents a "stack" of chooser targets for various activities within the same component.
@@ -30,18 +31,31 @@
public class MultiDisplayResolveInfo extends DisplayResolveInfo {
ArrayList<DisplayResolveInfo> mTargetInfos = new ArrayList<>();
- // We'll use this DRI for basic presentation info - eg icon, name.
- final DisplayResolveInfo mBaseInfo;
+
// Index of selected target
private int mSelected = -1;
/**
- * @param firstInfo A representative DRI to use for the main icon, title, etc for this Info.
+ * @param targetInfos A list of targets in this stack. The first item is treated as the
+ * "representative" that provides the main icon, title, etc.
*/
- public MultiDisplayResolveInfo(String packageName, DisplayResolveInfo firstInfo) {
- super(firstInfo);
- mBaseInfo = firstInfo;
- mTargetInfos.add(firstInfo);
+ public static MultiDisplayResolveInfo newMultiDisplayResolveInfo(
+ List<DisplayResolveInfo> targetInfos) {
+ return new MultiDisplayResolveInfo(targetInfos);
+ }
+
+ /**
+ * @param targetInfos A list of targets in this stack. The first item is treated as the
+ * "representative" that provides the main icon, title, etc.
+ */
+ private MultiDisplayResolveInfo(List<DisplayResolveInfo> targetInfos) {
+ super(targetInfos.get(0));
+ mTargetInfos = new ArrayList<>(targetInfos);
+ }
+
+ @Override
+ public final boolean isMultiDisplayResolveInfo() {
+ return true;
}
@Override
@@ -51,16 +65,12 @@
}
/**
- * Add another DisplayResolveInfo to the list included for this target.
+ * 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.
*/
- public void addTarget(DisplayResolveInfo target) {
- mTargetInfos.add(target);
- }
-
- /**
- * List of all DisplayResolveInfos included in this target.
- */
- public ArrayList<DisplayResolveInfo> getTargets() {
+ @Override
+ public ArrayList<DisplayResolveInfo> getAllDisplayTargets() {
return mTargetInfos;
}
@@ -96,5 +106,4 @@
public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
return mTargetInfos.get(mSelected).startAsUser(activity, options, user);
}
-
}
diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
index 220870f..d633337 100644
--- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java
@@ -18,12 +18,15 @@
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 android.service.chooser.ChooserTarget;
+import com.android.intentresolver.R;
import com.android.intentresolver.ResolverActivity;
import java.util.List;
@@ -32,7 +35,55 @@
* 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 implements ChooserTargetInfo {
+public abstract class NotSelectableTargetInfo extends ChooserTargetInfo {
+ /** Create a non-selectable {@link TargetInfo} with no content. */
+ public static TargetInfo newEmptyTargetInfo() {
+ return new NotSelectableTargetInfo() {
+ @Override
+ public boolean isEmptyTargetInfo() {
+ return true;
+ }
+ };
+ }
+
+ /**
+ * Create a non-selectable {@link TargetInfo} with placeholder content to be displayed
+ * 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;
@@ -78,10 +129,6 @@
return -0.1f;
}
- public ChooserTarget getChooserTarget() {
- return null;
- }
-
public boolean isSuspended() {
return false;
}
@@ -89,4 +136,17 @@
public boolean isPinned() {
return false;
}
+
+ @Override
+ public IconHolder getDisplayIconHolder() {
+ return new IconHolder() {
+ @Override
+ public Drawable getDisplayIcon() {
+ return null;
+ }
+
+ @Override
+ public void setDisplayIcon(Drawable icon) {}
+ };
+ }
}
diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
index 1610d0f..3ab5017 100644
--- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java
@@ -18,31 +18,23 @@
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.ActivityInfo;
-import android.content.pm.ApplicationInfo;
-import android.content.pm.LauncherApps;
-import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.ShortcutInfo;
-import android.graphics.Bitmap;
-import android.graphics.drawable.BitmapDrawable;
-import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.os.Bundle;
import android.os.UserHandle;
+import android.provider.DeviceConfig;
import android.service.chooser.ChooserTarget;
import android.text.SpannableStringBuilder;
+import android.util.HashedStringCache;
import android.util.Log;
-import com.android.intentresolver.ChooserActivity;
import com.android.intentresolver.ResolverActivity;
-import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter;
-import com.android.intentresolver.SimpleIconFactory;
-
-import com.android.internal.annotations.GuardedBy;
+import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
import java.util.ArrayList;
import java.util.List;
@@ -51,237 +43,312 @@
* Live target, currently selectable by the user.
* @see NotSelectableTargetInfo
*/
-public final class SelectableTargetInfo implements ChooserTargetInfo {
+public final class SelectableTargetInfo extends ChooserTargetInfo {
private static final String TAG = "SelectableTargetInfo";
- private final Context mContext;
+ private interface TargetHashProvider {
+ HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context);
+ }
+
+ private interface TargetActivityStarter {
+ boolean start(Activity activity, Bundle options);
+ boolean startAsCaller(Activity activity, Bundle options, int userId);
+ boolean startAsUser(Activity activity, Bundle options, UserHandle user);
+ }
+
+ private static final String HASHED_STRING_CACHE_TAG = "ChooserActivity"; // For legacy reasons.
+ private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7;
+
+ private final int mMaxHashSaltDays = DeviceConfig.getInt(
+ DeviceConfig.NAMESPACE_SYSTEMUI,
+ SystemUiDeviceConfigFlags.HASH_SALT_MAX_DAYS,
+ DEFAULT_SALT_EXPIRATION_DAYS);
+
+ @Nullable
private final DisplayResolveInfo mSourceInfo;
+ @Nullable
private final ResolveInfo mBackupResolveInfo;
- private final ChooserTarget mChooserTarget;
+ private final Intent mResolvedIntent;
private final String mDisplayLabel;
- private final PackageManager mPm;
- private final SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator;
- @GuardedBy("this")
- private ShortcutInfo mShortcutInfo;
- private Drawable mBadgeIcon = null;
- private CharSequence mBadgeContentDescription;
- @GuardedBy("this")
- private Drawable mDisplayIcon;
- private final Intent mFillInIntent;
+ @Nullable
+ private final AppTarget mAppTarget;
+ @Nullable
+ private final ShortcutInfo mShortcutInfo;
+
+ private final ComponentName mChooserTargetComponentName;
+ 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 boolean mIsSuspended = false;
+ private final boolean mIsSuspended;
+ private final ComponentName mResolvedComponentName;
+ private final Intent mBaseIntentToSend;
+ private final ResolveInfo mResolveInfo;
+ private final List<Intent> mAllSourceIntents;
+ private final IconHolder mDisplayIconHolder = new SettableIconHolder();
+ private final TargetHashProvider mHashProvider;
+ private final TargetActivityStarter mActivityStarter;
- public SelectableTargetInfo(Context context, DisplayResolveInfo sourceInfo,
+ /**
+ * 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}.
+ */
+ private final Intent mReferrerFillInIntent;
+
+ /**
+ * Create a new {@link TargetInfo} instance representing a selectable target. Some target
+ * parameters are copied over from the (deprecated) legacy {@link ChooserTarget} structure.
+ *
+ * @deprecated Use the overload that doesn't call for a {@link ChooserTarget}.
+ */
+ @Deprecated
+ public static TargetInfo newSelectableTargetInfo(
+ @Nullable DisplayResolveInfo sourceInfo,
+ @Nullable ResolveInfo backupResolveInfo,
+ Intent resolvedIntent,
ChooserTarget chooserTarget,
- float modifiedScore, SelectableTargetInfoCommunicator selectableTargetInfoComunicator,
- @Nullable ShortcutInfo shortcutInfo) {
- mContext = context;
+ float modifiedScore,
+ @Nullable ShortcutInfo shortcutInfo,
+ @Nullable AppTarget appTarget,
+ Intent referrerFillInIntent) {
+ return newSelectableTargetInfo(
+ sourceInfo,
+ backupResolveInfo,
+ resolvedIntent,
+ chooserTarget.getComponentName(),
+ chooserTarget.getTitle(),
+ chooserTarget.getIcon(),
+ chooserTarget.getIntentExtras(),
+ modifiedScore,
+ shortcutInfo,
+ appTarget,
+ referrerFillInIntent);
+ }
+
+ /**
+ * Create a new {@link TargetInfo} instance representing a selectable target. `chooserTarget*`
+ * parameters were historically retrieved from (now-deprecated) {@link ChooserTarget} structures
+ * even when the {@link TargetInfo} was a system (internal) synthesized target that never needed
+ * to be represented as a {@link ChooserTarget}. The values passed here are copied in directly
+ * as if they had been provided in the legacy representation.
+ *
+ * TODO: clarify semantics of how clients use the `getChooserTarget*()` methods; refactor/rename
+ * to avoid making reference to the legacy type; and reflect the improved semantics in the
+ * signature (and documentation) of this method.
+ */
+ public static TargetInfo newSelectableTargetInfo(
+ @Nullable DisplayResolveInfo sourceInfo,
+ @Nullable ResolveInfo backupResolveInfo,
+ Intent resolvedIntent,
+ ComponentName chooserTargetComponentName,
+ CharSequence chooserTargetUnsanitizedTitle,
+ Icon chooserTargetIcon,
+ @Nullable Bundle chooserTargetIntentExtras,
+ float modifiedScore,
+ @Nullable ShortcutInfo shortcutInfo,
+ @Nullable AppTarget appTarget,
+ Intent referrerFillInIntent) {
+ return new SelectableTargetInfo(
+ sourceInfo,
+ backupResolveInfo,
+ resolvedIntent,
+ chooserTargetComponentName,
+ chooserTargetUnsanitizedTitle,
+ chooserTargetIcon,
+ chooserTargetIntentExtras,
+ modifiedScore,
+ shortcutInfo,
+ appTarget,
+ referrerFillInIntent,
+ /* fillInIntent = */ null,
+ /* fillInFlags = */ 0);
+ }
+
+ private SelectableTargetInfo(
+ @Nullable DisplayResolveInfo sourceInfo,
+ @Nullable ResolveInfo backupResolveInfo,
+ Intent resolvedIntent,
+ ComponentName chooserTargetComponentName,
+ CharSequence chooserTargetUnsanitizedTitle,
+ Icon chooserTargetIcon,
+ Bundle chooserTargetIntentExtras,
+ float modifiedScore,
+ @Nullable ShortcutInfo shortcutInfo,
+ @Nullable AppTarget appTarget,
+ Intent referrerFillInIntent,
+ @Nullable Intent fillInIntent,
+ int fillInFlags) {
mSourceInfo = sourceInfo;
- mChooserTarget = chooserTarget;
+ mBackupResolveInfo = backupResolveInfo;
+ mResolvedIntent = resolvedIntent;
mModifiedScore = modifiedScore;
- mPm = mContext.getPackageManager();
- mSelectableTargetInfoCommunicator = selectableTargetInfoComunicator;
mShortcutInfo = shortcutInfo;
- mIsPinned = shortcutInfo != null && shortcutInfo.isPinned();
- if (sourceInfo != null) {
- final ResolveInfo ri = sourceInfo.getResolveInfo();
- if (ri != null) {
- final ActivityInfo ai = ri.activityInfo;
- if (ai != null && ai.applicationInfo != null) {
- final PackageManager pm = mContext.getPackageManager();
- mBadgeIcon = pm.getApplicationIcon(ai.applicationInfo);
- mBadgeContentDescription = pm.getApplicationLabel(ai.applicationInfo);
- mIsSuspended =
- (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0;
- }
- }
- }
-
- if (sourceInfo != null) {
- mBackupResolveInfo = null;
- } else {
- mBackupResolveInfo =
- mContext.getPackageManager().resolveActivity(getResolvedIntent(), 0);
- }
-
- mFillInIntent = null;
- mFillInFlags = 0;
-
- mDisplayLabel = sanitizeDisplayLabel(chooserTarget.getTitle());
- }
-
- private SelectableTargetInfo(SelectableTargetInfo other,
- Intent fillInIntent, int flags) {
- mContext = other.mContext;
- mPm = other.mPm;
- mSelectableTargetInfoCommunicator = other.mSelectableTargetInfoCommunicator;
- mSourceInfo = other.mSourceInfo;
- mBackupResolveInfo = other.mBackupResolveInfo;
- mChooserTarget = other.mChooserTarget;
- mBadgeIcon = other.mBadgeIcon;
- mBadgeContentDescription = other.mBadgeContentDescription;
- synchronized (other) {
- mShortcutInfo = other.mShortcutInfo;
- mDisplayIcon = other.mDisplayIcon;
- }
+ mAppTarget = appTarget;
+ mReferrerFillInIntent = referrerFillInIntent;
mFillInIntent = fillInIntent;
- mFillInFlags = flags;
- mModifiedScore = other.mModifiedScore;
- mIsPinned = other.mIsPinned;
+ mFillInFlags = fillInFlags;
+ mChooserTargetComponentName = chooserTargetComponentName;
+ mChooserTargetUnsanitizedTitle = chooserTargetUnsanitizedTitle;
+ mChooserTargetIcon = chooserTargetIcon;
+ mChooserTargetIntentExtras = chooserTargetIntentExtras;
- mDisplayLabel = sanitizeDisplayLabel(mChooserTarget.getTitle());
+ mIsPinned = (shortcutInfo != null) && shortcutInfo.isPinned();
+ mDisplayLabel = sanitizeDisplayLabel(mChooserTargetUnsanitizedTitle);
+ mIsSuspended = (mSourceInfo != null) && mSourceInfo.isSuspended();
+ mResolveInfo = (mSourceInfo != null) ? mSourceInfo.getResolveInfo() : mBackupResolveInfo;
+
+ mResolvedComponentName = getResolvedComponentName(mSourceInfo, mBackupResolveInfo);
+
+ mAllSourceIntents = getAllSourceIntents(sourceInfo);
+
+ mBaseIntentToSend = getBaseIntentToSend(
+ mResolvedIntent,
+ mFillInIntent,
+ mFillInFlags,
+ mReferrerFillInIntent);
+
+ mHashProvider = context -> {
+ final String plaintext =
+ getChooserTargetComponentName().getPackageName()
+ + mChooserTargetUnsanitizedTitle;
+ return HashedStringCache.getInstance().hashString(
+ context,
+ HASHED_STRING_CACHE_TAG,
+ plaintext,
+ mMaxHashSaltDays);
+ };
+
+ mActivityStarter = new TargetActivityStarter() {
+ @Override
+ public boolean start(Activity activity, Bundle options) {
+ throw new RuntimeException("ChooserTargets should be started as caller.");
+ }
+
+ @Override
+ public boolean startAsCaller(Activity activity, Bundle options, int userId) {
+ final Intent intent = mBaseIntentToSend;
+ if (intent == null) {
+ return false;
+ }
+ intent.setComponent(getChooserTargetComponentName());
+ intent.putExtras(mChooserTargetIntentExtras);
+ TargetInfo.prepareIntentForCrossProfileLaunch(intent, userId);
+
+ // Important: we will ignore the target security checks in ActivityManager if and
+ // only if the ChooserTarget's target package is the same package where we got the
+ // ChooserTargetService that provided it. This lets a ChooserTargetService provide
+ // a non-exported or permission-guarded target for the user to pick.
+ //
+ // If mSourceInfo is null, we got this ChooserTarget from the caller or elsewhere
+ // so we'll obey the caller's normal security checks.
+ final boolean ignoreTargetSecurity = (mSourceInfo != null)
+ && mSourceInfo.getResolvedComponentName().getPackageName()
+ .equals(getChooserTargetComponentName().getPackageName());
+ activity.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId);
+ return true;
+ }
+
+ @Override
+ public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
+ throw new RuntimeException("ChooserTargets should be started as caller.");
+ }
+ };
}
- private String sanitizeDisplayLabel(CharSequence label) {
- SpannableStringBuilder sb = new SpannableStringBuilder(label);
- sb.clearSpans();
- return sb.toString();
+ private SelectableTargetInfo(SelectableTargetInfo other, Intent fillInIntent, int flags) {
+ this(
+ other.mSourceInfo,
+ other.mBackupResolveInfo,
+ other.mResolvedIntent,
+ other.mChooserTargetComponentName,
+ other.mChooserTargetUnsanitizedTitle,
+ other.mChooserTargetIcon,
+ other.mChooserTargetIntentExtras,
+ other.mModifiedScore,
+ other.mShortcutInfo,
+ other.mAppTarget,
+ other.mReferrerFillInIntent,
+ fillInIntent,
+ flags);
}
+ @Override
+ public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) {
+ return new SelectableTargetInfo(this, fillInIntent, flags);
+ }
+
+ @Override
+ public HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) {
+ return mHashProvider.getHashedTargetIdForMetrics(context);
+ }
+
+ @Override
+ public boolean isSelectableTargetInfo() {
+ return true;
+ }
+
+ @Override
public boolean isSuspended() {
return mIsSuspended;
}
+ @Override
+ @Nullable
public DisplayResolveInfo getDisplayResolveInfo() {
return mSourceInfo;
}
- /**
- * Load display icon, if needed.
- */
- public void loadIcon() {
- ShortcutInfo shortcutInfo;
- Drawable icon;
- synchronized (this) {
- shortcutInfo = mShortcutInfo;
- icon = mDisplayIcon;
- }
- if (icon == null && shortcutInfo != null) {
- icon = getChooserTargetIconDrawable(mChooserTarget, shortcutInfo);
- synchronized (this) {
- mDisplayIcon = icon;
- mShortcutInfo = null;
- }
- }
- }
-
- private Drawable getChooserTargetIconDrawable(ChooserTarget target,
- @Nullable ShortcutInfo shortcutInfo) {
- Drawable directShareIcon = null;
-
- // First get the target drawable and associated activity info
- final Icon icon = target.getIcon();
- if (icon != null) {
- directShareIcon = icon.loadDrawable(mContext);
- } else if (shortcutInfo != null) {
- LauncherApps launcherApps = (LauncherApps) mContext.getSystemService(
- Context.LAUNCHER_APPS_SERVICE);
- directShareIcon = launcherApps.getShortcutIconDrawable(shortcutInfo, 0);
- }
-
- if (directShareIcon == null) return null;
-
- ActivityInfo info = null;
- try {
- info = mPm.getActivityInfo(target.getComponentName(), 0);
- } catch (PackageManager.NameNotFoundException error) {
- Log.e(TAG, "Could not find activity associated with ChooserTarget");
- }
-
- if (info == null) return null;
-
- // Now fetch app icon and raster with no badging even in work profile
- Bitmap appIcon = mSelectableTargetInfoCommunicator.makePresentationGetter(info)
- .getIconBitmap(null);
-
- // Raster target drawable with appIcon as a badge
- SimpleIconFactory sif = SimpleIconFactory.obtain(mContext);
- Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon);
- sif.recycle();
-
- return new BitmapDrawable(mContext.getResources(), directShareBadgedIcon);
- }
-
+ @Override
public float getModifiedScore() {
return mModifiedScore;
}
@Override
public Intent getResolvedIntent() {
- if (mSourceInfo != null) {
- return mSourceInfo.getResolvedIntent();
- }
-
- final Intent targetIntent = new Intent(mSelectableTargetInfoCommunicator.getTargetIntent());
- targetIntent.setComponent(mChooserTarget.getComponentName());
- targetIntent.putExtras(mChooserTarget.getIntentExtras());
- return targetIntent;
+ return mResolvedIntent;
}
@Override
public ComponentName getResolvedComponentName() {
- if (mSourceInfo != null) {
- return mSourceInfo.getResolvedComponentName();
- } else if (mBackupResolveInfo != null) {
- return new ComponentName(mBackupResolveInfo.activityInfo.packageName,
- mBackupResolveInfo.activityInfo.name);
- }
- return null;
+ return mResolvedComponentName;
}
- private Intent getBaseIntentToSend() {
- Intent result = getResolvedIntent();
- if (result == null) {
- Log.e(TAG, "ChooserTargetInfo: no base intent available to send");
- } else {
- result = new Intent(result);
- if (mFillInIntent != null) {
- result.fillIn(mFillInIntent, mFillInFlags);
- }
- result.fillIn(mSelectableTargetInfoCommunicator.getReferrerFillInIntent(), 0);
- }
- return result;
+ @Override
+ public ComponentName getChooserTargetComponentName() {
+ return mChooserTargetComponentName;
+ }
+
+ @Nullable
+ public Icon getChooserTargetIcon() {
+ return mChooserTargetIcon;
}
@Override
public boolean start(Activity activity, Bundle options) {
- throw new RuntimeException("ChooserTargets should be started as caller.");
+ return mActivityStarter.start(activity, options);
}
@Override
public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) {
- final Intent intent = getBaseIntentToSend();
- if (intent == null) {
- return false;
- }
- intent.setComponent(mChooserTarget.getComponentName());
- intent.putExtras(mChooserTarget.getIntentExtras());
-
- // Important: we will ignore the target security checks in ActivityManager
- // if and only if the ChooserTarget's target package is the same package
- // where we got the ChooserTargetService that provided it. This lets a
- // ChooserTargetService provide a non-exported or permission-guarded target
- // to the chooser for the user to pick.
- //
- // If mSourceInfo is null, we got this ChooserTarget from the caller or elsewhere
- // so we'll obey the caller's normal security checks.
- final boolean ignoreTargetSecurity = mSourceInfo != null
- && mSourceInfo.getResolvedComponentName().getPackageName()
- .equals(mChooserTarget.getComponentName().getPackageName());
- activity.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId);
- return true;
+ return mActivityStarter.startAsCaller(activity, options, userId);
}
@Override
public boolean startAsUser(Activity activity, Bundle options, UserHandle user) {
- throw new RuntimeException("ChooserTargets should be started as caller.");
+ return mActivityStarter.startAsUser(activity, options, user);
}
@Override
public ResolveInfo getResolveInfo() {
- return mSourceInfo != null ? mSourceInfo.getResolveInfo() : mBackupResolveInfo;
+ return mResolveInfo;
}
@Override
@@ -296,27 +363,25 @@
}
@Override
- public synchronized Drawable getDisplayIcon(Context context) {
- return mDisplayIcon;
- }
-
- public ChooserTarget getChooserTarget() {
- return mChooserTarget;
+ public IconHolder getDisplayIconHolder() {
+ return mDisplayIconHolder;
}
@Override
- public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) {
- return new SelectableTargetInfo(this, fillInIntent, flags);
+ @Nullable
+ public ShortcutInfo getDirectShareShortcutInfo() {
+ return mShortcutInfo;
+ }
+
+ @Override
+ @Nullable
+ public AppTarget getDirectShareAppTarget() {
+ return mAppTarget;
}
@Override
public List<Intent> getAllSourceIntents() {
- final List<Intent> results = new ArrayList<>();
- if (mSourceInfo != null) {
- // We only queried the service for the first one in our sourceinfo.
- results.add(mSourceInfo.getAllSourceIntents().get(0));
- }
- return results;
+ return mAllSourceIntents;
}
@Override
@@ -324,16 +389,49 @@
return mIsPinned;
}
- /**
- * Necessary methods to communicate between {@link SelectableTargetInfo}
- * and {@link ResolverActivity} or {@link ChooserActivity}.
- */
- public interface SelectableTargetInfoCommunicator {
+ private static String sanitizeDisplayLabel(CharSequence label) {
+ SpannableStringBuilder sb = new SpannableStringBuilder(label);
+ sb.clearSpans();
+ return sb.toString();
+ }
- ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo info);
+ private static List<Intent> getAllSourceIntents(@Nullable DisplayResolveInfo sourceInfo) {
+ final List<Intent> results = new ArrayList<>();
+ if (sourceInfo != null) {
+ // We only queried the service for the first one in our sourceinfo.
+ results.add(sourceInfo.getAllSourceIntents().get(0));
+ }
+ return results;
+ }
- Intent getTargetIntent();
+ private static ComponentName getResolvedComponentName(
+ @Nullable DisplayResolveInfo sourceInfo, ResolveInfo backupResolveInfo) {
+ if (sourceInfo != null) {
+ return sourceInfo.getResolvedComponentName();
+ } else if (backupResolveInfo != null) {
+ return new ComponentName(
+ backupResolveInfo.activityInfo.packageName,
+ backupResolveInfo.activityInfo.name);
+ }
+ return null;
+ }
- Intent getReferrerFillInIntent();
+ @Nullable
+ private static Intent getBaseIntentToSend(
+ @Nullable Intent resolvedIntent,
+ Intent fillInIntent,
+ int fillInFlags,
+ Intent referrerFillInIntent) {
+ Intent result = resolvedIntent;
+ 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 fabb26c..72dd1b0 100644
--- a/java/src/com/android/intentresolver/chooser/TargetInfo.java
+++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java
@@ -17,23 +17,67 @@
package com.android.intentresolver.chooser;
+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.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.UserHandle;
+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;
/**
* A single target as represented in the chooser.
*/
public interface TargetInfo {
+
+ /**
+ * Container for a {@link TargetInfo}'s (potentially) mutable icon state. This is provided to
+ * encapsulate the state so that the {@link TargetInfo} itself can be "immutable" (in some
+ * sense) as long as it always returns the same {@link IconHolder} instance.
+ *
+ * TODO: move "stateful" responsibilities out to clients; for more info see the Javadoc comment
+ * on {@link #getDisplayIconHolder()}.
+ */
+ interface IconHolder {
+ /** @return the icon (if it's already loaded, or statically available), or null. */
+ @Nullable
+ Drawable getDisplayIcon();
+
+ /**
+ * @param icon the icon to return on subsequent calls to {@link #getDisplayIcon()}.
+ * Implementations may discard this request as a no-op if they don't support setting.
+ */
+ void setDisplayIcon(Drawable icon);
+ }
+
+ /** A simple mutable-container implementation of {@link IconHolder}. */
+ final class SettableIconHolder implements IconHolder {
+ @Nullable
+ private Drawable mDisplayIcon;
+
+ @Nullable
+ public Drawable getDisplayIcon() {
+ return mDisplayIcon;
+ }
+
+ public void setDisplayIcon(Drawable icon) {
+ mDisplayIcon = icon;
+ }
+ }
+
/**
* Get the resolved intent that represents this target. Note that this may not be the
* intent that will be launched by calling one of the <code>start</code> methods provided;
@@ -46,13 +90,34 @@
/**
* 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.
+ * methods provided; this is the component that will be credited with the launch. This may be
+ * null if the target was specified by a caller-provided {@link ChooserTarget} that we failed to
+ * resolve to a component on the system.
*
* @return the resolved ComponentName for this target
*/
+ @Nullable
ComponentName getResolvedComponentName();
/**
+ * If this target was historically built from a (now-deprecated) {@link ChooserTarget} record,
+ * get the {@link ComponentName} that would've been provided by that record.
+ *
+ * TODO: for (historical) {@link ChooserTargetInfo} targets, this differs from the result of
+ * {@link #getResolvedComponentName()} only for caller-provided targets that we fail to resolve;
+ * then this returns the name of the component that was requested, and the other returns null.
+ * At the time of writing, this method is only called in contexts where the client knows that
+ * the target was a historical {@link ChooserTargetInfo}. Thus this method could be removed and
+ * all clients consolidated on the other, if we have some alternate mechanism of tracking this
+ * discrepancy; or if we know that the distinction won't apply in the conditions when we call
+ * this method; or if we determine that tracking the distinction isn't a requirement for us.
+ */
+ @Nullable
+ default ComponentName getChooserTargetComponentName() {
+ return null;
+ }
+
+ /**
* Start the activity referenced by this target.
*
* @param activity calling Activity performing the launch
@@ -106,12 +171,23 @@
CharSequence getExtendedInfo();
/**
- * @return The drawable that should be used to represent this target including badge
- * @param context
+ * @return the {@link IconHolder} for the icon used to represent this target, including badge.
+ *
+ * TODO: while the {@link TargetInfo} may be immutable in always returning the same instance of
+ * {@link IconHolder} here, the holder itself is mutable state, and could become a problem if we
+ * ever rely on {@link TargetInfo} immutability elsewhere. Ideally, the {@link TargetInfo}
+ * should provide an immutable "spec" that tells clients <em>how</em> to load the appropriate
+ * icon, while leaving the load itself to some external component.
*/
- Drawable getDisplayIcon(Context context);
+ IconHolder getDisplayIconHolder();
/**
+ * @return true if display icon is available.
+ */
+ default boolean hasDisplayIcon() {
+ return getDisplayIconHolder().getDisplayIcon() != null;
+ }
+ /**
* Clone this target with the given fill-in information.
*/
TargetInfo cloneFilledIn(Intent fillInIntent, int flags);
@@ -122,6 +198,28 @@
List<Intent> getAllSourceIntents();
/**
+ * @return the one or more {@link DisplayResolveInfo}s that this target represents in the UI.
+ *
+ * TODO: clarify the semantics of the {@link DisplayResolveInfo} branch of {@link TargetInfo}'s
+ * class hierarchy. Why is it that {@link MultiDisplayResolveInfo} can stand in for some
+ * "virtual" {@link DisplayResolveInfo} targets that aren't individually represented in the UI,
+ * but OTOH a {@link ChooserTargetInfo} (which doesn't inherit from {@link DisplayResolveInfo})
+ * can't provide its own UI treatment, and instead needs us to reach into its composed-in
+ * info via {@link #getDisplayResolveInfo()}? It seems like {@link DisplayResolveInfo} may be
+ * required to populate views in our UI, while {@link ChooserTargetInfo} may carry some other
+ * metadata. For non-{@link ChooserTargetInfo} targets (e.g. in {@link ResolverActivity}) the
+ * "naked" {@link DisplayResolveInfo} might also be taken to provide some of this metadata, but
+ * this presents a denormalization hazard since the "UI info" ({@link DisplayResolveInfo}) that
+ * represents a {@link ChooserTargetInfo} might provide different values than its enclosing
+ * {@link ChooserTargetInfo} (as they both implement {@link TargetInfo}). We could try to
+ * address this by splitting {@link DisplayResolveInfo} into two types; one (which implements
+ * the same {@link TargetInfo} interface as {@link ChooserTargetInfo}) provides the previously-
+ * implicit "metadata", and the other provides only the UI treatment for a target of any type
+ * (taking over the respective methods that previously belonged to {@link TargetInfo}).
+ */
+ ArrayList<DisplayResolveInfo> getAllDisplayTargets();
+
+ /**
* @return true if this target cannot be selected by the user
*/
boolean isSuspended();
@@ -130,4 +228,220 @@
* @return true if this target should be pinned to the front by the request of the user
*/
boolean isPinned();
+
+ /**
+ * Determine whether two targets represent "similar" content that could be de-duped.
+ * Note an earlier version of this code cautioned maintainers,
+ * "do not label as 'equals', since this doesn't quite work as intended with java 8."
+ * This seems to refer to the rule that interfaces can't provide defaults that conflict with the
+ * definitions of "real" methods in {@code java.lang.Object}, and (if desired) it could be
+ * presumably resolved by converting {@code TargetInfo} from an interface to an abstract class.
+ */
+ default boolean isSimilar(TargetInfo other) {
+ if (other == null) {
+ return false;
+ }
+
+ // TODO: audit usage and try to reconcile a behavior that doesn't depend on the legacy
+ // subclass type. Note that the `isSimilar()` method was pulled up from the legacy
+ // `ChooserTargetInfo`, so no legacy behavior currently depends on calling `isSimilar()` on
+ // an instance where `isChooserTargetInfo()` would return false (although technically it may
+ // have been possible for the `other` target to be of a different type). Thus we have
+ // flexibility in defining the similarity conditions between pairs of non "chooser" targets.
+ if (isChooserTargetInfo()) {
+ return other.isChooserTargetInfo()
+ && Objects.equals(
+ getChooserTargetComponentName(), other.getChooserTargetComponentName())
+ && TextUtils.equals(getDisplayLabel(), other.getDisplayLabel())
+ && TextUtils.equals(getExtendedInfo(), other.getExtendedInfo());
+ } else {
+ return !other.isChooserTargetInfo() && Objects.equals(this, other);
+ }
+ }
+
+ /**
+ * @return the target score, including any Chooser-specific modifications that may have been
+ * applied (either overriding by special-case for "non-selectable" targets, or by twiddling the
+ * scores of "selectable" targets in {@link ChooserListAdapter}). Higher scores are "better."
+ * Targets that aren't intended for ranking/scoring should return a negative value.
+ */
+ default float getModifiedScore() {
+ return -0.1f;
+ }
+
+ /**
+ * @return the {@link ShortcutManager} data for any shortcut associated with this target.
+ */
+ @Nullable
+ default ShortcutInfo getDirectShareShortcutInfo() {
+ return null;
+ }
+
+ /**
+ * @return the ID of the shortcut represented by this target, or null if the target didn't come
+ * from a {@link ShortcutManager} shortcut.
+ */
+ @Nullable
+ default String getDirectShareShortcutId() {
+ ShortcutInfo shortcut = getDirectShareShortcutInfo();
+ if (shortcut == null) {
+ return null;
+ }
+ return shortcut.getId();
+ }
+
+ /**
+ * @return the {@link AppTarget} metadata if this target was sourced from App Prediction
+ * service, or null otherwise.
+ */
+ @Nullable
+ default AppTarget getDirectShareAppTarget() {
+ return null;
+ }
+
+ /**
+ * Get more info about this target in the form of a {@link DisplayResolveInfo}, if available.
+ * TODO: this seems to return non-null only for ChooserTargetInfo subclasses. Determine the
+ * meaning of a TargetInfo (ChooserTargetInfo) embedding another kind of TargetInfo
+ * (DisplayResolveInfo) in this way, and - at least - improve this documentation; OTOH this
+ * probably indicates an opportunity to simplify or better separate these APIs. (For example,
+ * targets that <em>don't</em> descend from ChooserTargetInfo instead descend directly from
+ * DisplayResolveInfo; should they return `this`? Do we always use DisplayResolveInfo to
+ * represent visual properties, and then either assume some implicit metadata properties *or*
+ * embed that visual representation within a ChooserTargetInfo to carry additional metadata? If
+ * that's the case, maybe we could decouple by saying that all TargetInfos compose-in their
+ * visual representation [as a DisplayResolveInfo, now the root of its own class hierarchy] and
+ * then add a new TargetInfo type that explicitly represents the "implicit metadata" that we
+ * previously assumed for "naked DisplayResolveInfo targets" that weren't wrapped as
+ * ChooserTargetInfos. Or does all this complexity disappear once we stop relying on the
+ * deprecated ChooserTarget type?)
+ */
+ @Nullable
+ default DisplayResolveInfo getDisplayResolveInfo() {
+ return null;
+ }
+
+ /**
+ * @return true if this target represents a legacy {@code ChooserTargetInfo}. These objects were
+ * historically documented as representing "[a] TargetInfo for Direct Share." However, not all
+ * of these targets are actually *valid* for direct share; e.g. some represent "empty" items
+ * (although perhaps only for display in the Direct Share UI?). In even earlier versions, these
+ * targets may also have been results from peers in the (now-deprecated/unsupported)
+ * {@code ChooserTargetService} ecosystem; even though we no longer use these services, we're
+ * still shoehorning other target data into the deprecated {@link ChooserTarget} structure for
+ * compatibility with some internal APIs.
+ * TODO: refactor to clarify the semantics of any target for which this method returns true
+ * (e.g., are they characterized by their application in the Direct Share UI?), and to remove
+ * the scaffolding that adapts to and from the {@link ChooserTarget} structure. Eventually, we
+ * expect to remove this method (and others that strictly indicate legacy subclass roles) in
+ * favor of a more semantic design that expresses the purpose and distinctions in those roles.
+ */
+ default boolean isChooserTargetInfo() {
+ return false;
+ }
+
+ /**
+ * @return true if this target represents a legacy {@code DisplayResolveInfo}. These objects
+ * were historically documented as an augmented "TargetInfo plus additional information needed
+ * to render it (such as icon and label) and resolve it to an activity." That description in no
+ * way distinguishes from the base {@code TargetInfo} API. At the time of writing, these objects
+ * are most-clearly defined by their opposite; this returns true for exactly those instances of
+ * {@code TargetInfo} where {@link #isChooserTargetInfo()} returns false (these conditions are
+ * complementary because they correspond to the immediate {@code TargetInfo} child types that
+ * historically partitioned all concrete {@code TargetInfo} implementations). These may(?)
+ * represent any target displayed somewhere other than the Direct Share UI.
+ */
+ default boolean isDisplayResolveInfo() {
+ return false;
+ }
+
+ /**
+ * @return true if this target represents a legacy {@code MultiDisplayResolveInfo}. These
+ * objects were historically documented as representing "a 'stack' of chooser targets for
+ * various activities within the same component." For historical reasons this currently can
+ * return true only if {@link #isDisplayResolveInfo()} returns true (because the legacy classes
+ * shared an inheritance relationship), but new code should avoid relying on that relationship
+ * since these APIs are "in transition."
+ */
+ default boolean isMultiDisplayResolveInfo() {
+ return false;
+ }
+
+ /**
+ * @return true if this target represents a legacy {@code SelectableTargetInfo}. Note that this
+ * is defined for legacy compatibility and may not conform to other notions of a "selectable"
+ * target. For historical reasons, this method and {@link #isNotSelectableTargetInfo()} only
+ * partition the {@code TargetInfo} instances for which {@link #isChooserTargetInfo()} returns
+ * true; otherwise <em>both</em> methods return false.
+ * TODO: define selectability for targets not historically from {@code ChooserTargetInfo},
+ * then attempt to replace this with a new method like {@code TargetInfo#isSelectable()} that
+ * actually partitions <em>all</em> target types (after updating client usage as needed).
+ */
+ default boolean isSelectableTargetInfo() {
+ return false;
+ }
+
+ /**
+ * @return true if this target represents a legacy {@code NotSelectableTargetInfo} (i.e., a
+ * target where {@link #isChooserTargetInfo()} is true but {@link #isSelectableTargetInfo()} is
+ * false). For more information on how this divides the space of targets, see the Javadoc for
+ * {@link #isSelectableTargetInfo()}.
+ */
+ default boolean isNotSelectableTargetInfo() {
+ return false;
+ }
+
+ /**
+ * @return true if this target represents a legacy {@code ChooserActivity#EmptyTargetInfo}. Note
+ * that this is defined for legacy compatibility and may not conform to other notions of an
+ * "empty" target.
+ */
+ default boolean isEmptyTargetInfo() {
+ return false;
+ }
+
+ /**
+ * @return true if this target represents a legacy {@code ChooserActivity#PlaceHolderTargetInfo}
+ * (defined only for compatibility with historic use in {@link ChooserListAdapter}). For
+ * historic reasons (owing to a legacy subclass relationship) this can return true only if
+ * {@link #isNotSelectableTargetInfo()} also returns true.
+ */
+ default boolean isPlaceHolderTargetInfo() {
+ return false;
+ }
+
+ /**
+ * @return true if this target should be logged with the "direct_share" metrics category in
+ * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch()}. This is defined for legacy
+ * compatibility and is <em>not</em> likely to be a good indicator of whether this is actually a
+ * "direct share" target (e.g. because it historically also applies to "empty" and "placeholder"
+ * targets).
+ */
+ default boolean isInDirectShareMetricsCategory() {
+ return isChooserTargetInfo();
+ }
+
+ /**
+ * @param context caller's context, to provide the {@link SharedPreferences} for use by the
+ * {@link HashedStringCache}.
+ * @return a hashed ID that should be logged along with our target-selection metrics, or null.
+ * The contents of the plaintext are defined for historical reasons, "the package name + target
+ * name to answer the question if most users share to mostly the same person
+ * or to a bunch of different people." Clients should consider this as opaque data for logging
+ * only; they should not rely on any particular semantics about the value.
+ */
+ default HashedStringCache.HashResult getHashedTargetIdForMetrics(Context context) {
+ return null;
+ }
+
+ /**
+ * Fix the URIs in {@code intent} if cross-profile sharing is required. This should be called
+ * before launching the intent as another user.
+ */
+ static void prepareIntentForCrossProfileLaunch(Intent intent, int targetUserId) {
+ final int currentUserId = UserHandle.myUserId();
+ if (targetUserId != currentUserId) {
+ intent.fixUris(currentUserId);
+ }
+ }
}
diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
new file mode 100644
index 0000000..1cf5931
--- /dev/null
+++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java
@@ -0,0 +1,604 @@
+/*
+ * 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.grid;
+
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.app.ActivityManager;
+import android.content.Context;
+import android.database.DataSetObserver;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.Space;
+import android.widget.TextView;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.intentresolver.ChooserListAdapter;
+import com.android.intentresolver.R;
+import com.android.intentresolver.ResolverListAdapter.ViewHolder;
+import com.android.internal.annotations.VisibleForTesting;
+
+import com.google.android.collect.Lists;
+
+/**
+ * Adapter for all types of items and targets in ShareSheet.
+ * Note that ranked sections like Direct Share - while appearing grid-like - are handled on the
+ * row level by this adapter but not on the item level. Individual targets within the row are
+ * handled by {@link ChooserListAdapter}
+ */
+@VisibleForTesting
+public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
+
+ /**
+ * The transition time between placeholders for direct share to a message
+ * indicating that none are available.
+ */
+ public static final int NO_DIRECT_SHARE_ANIM_IN_MILLIS = 200;
+
+ /**
+ * Injectable interface for any considerations that should be delegated to other components
+ * in the {@link ChooserActivity}.
+ * TODO: determine whether any of these methods return parameters that can safely be
+ * precomputed; whether any should be converted to `ChooserGridAdapter` setters to be
+ * invoked by external callbacks; and whether any reflect requirements that should be moved
+ * out of `ChooserGridAdapter` altogether.
+ */
+ public interface ChooserActivityDelegate {
+ /** @return whether we're showing a tabbed (multi-profile) UI. */
+ boolean shouldShowTabs();
+
+ /**
+ * @return a content preview {@link View} that's appropriate for the caller's share
+ * content, constructed for display in the provided {@code parent} group.
+ */
+ View buildContentPreview(ViewGroup parent);
+
+ /** Notify the client that the item with the selected {@code itemIndex} was selected. */
+ void onTargetSelected(int itemIndex);
+
+ /**
+ * Notify the client that the item with the selected {@code itemIndex} was
+ * long-pressed.
+ */
+ void onTargetLongPressed(int itemIndex);
+
+ /**
+ * Notify the client that the provided {@code View} should be configured as the new
+ * "profile view" button. Callers should attach their own click listeners to implement
+ * behaviors on this view.
+ */
+ void updateProfileViewButton(View newButtonFromProfileRow);
+
+ /**
+ * @return the number of "valid" targets in the active list adapter.
+ * TODO: define "valid."
+ */
+ int getValidTargetCount();
+
+ /**
+ * Request that the client update our {@code directShareGroup} to match their desired
+ * state for the "expansion" UI.
+ */
+ void updateDirectShareExpansion(DirectShareViewHolder directShareGroup);
+
+ /**
+ * Request that the client handle a scroll event that should be taken as expanding the
+ * provided {@code directShareGroup}. Note that this currently never happens due to a
+ * hard-coded condition in {@link #canExpandDirectShare()}.
+ */
+ void handleScrollToExpandDirectShare(
+ DirectShareViewHolder directShareGroup, int y, int oldy);
+ }
+
+ private static final int VIEW_TYPE_DIRECT_SHARE = 0;
+ private static final int VIEW_TYPE_NORMAL = 1;
+ private static final int VIEW_TYPE_CONTENT_PREVIEW = 2;
+ private static final int VIEW_TYPE_PROFILE = 3;
+ private static final int VIEW_TYPE_AZ_LABEL = 4;
+ private static final int VIEW_TYPE_CALLER_AND_RANK = 5;
+ private static final int VIEW_TYPE_FOOTER = 6;
+
+ private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20;
+
+ private final ChooserActivityDelegate mChooserActivityDelegate;
+ private final ChooserListAdapter mChooserListAdapter;
+ private final LayoutInflater mLayoutInflater;
+
+ private final int mMaxTargetsPerRow;
+ private final boolean mShouldShowContentPreview;
+ private final int mChooserWidthPixels;
+ private final int mChooserRowTextOptionTranslatePixelSize;
+ private final boolean mShowAzLabelIfPoss;
+
+ private DirectShareViewHolder mDirectShareViewHolder;
+ private int mChooserTargetWidth = 0;
+
+ private int mFooterHeight = 0;
+
+ public ChooserGridAdapter(
+ Context context,
+ ChooserActivityDelegate chooserActivityDelegate,
+ ChooserListAdapter wrappedAdapter,
+ boolean shouldShowContentPreview,
+ int maxTargetsPerRow,
+ int numSheetExpansions) {
+ super();
+
+ mChooserActivityDelegate = chooserActivityDelegate;
+
+ mChooserListAdapter = wrappedAdapter;
+ mLayoutInflater = LayoutInflater.from(context);
+
+ mShouldShowContentPreview = shouldShowContentPreview;
+ mMaxTargetsPerRow = maxTargetsPerRow;
+
+ mChooserWidthPixels = context.getResources().getDimensionPixelSize(R.dimen.chooser_width);
+ mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize(
+ R.dimen.chooser_row_text_option_translate);
+
+ mShowAzLabelIfPoss = numSheetExpansions < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL;
+
+ wrappedAdapter.registerDataSetObserver(new DataSetObserver() {
+ @Override
+ public void onChanged() {
+ super.onChanged();
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onInvalidated() {
+ super.onInvalidated();
+ notifyDataSetChanged();
+ }
+ });
+ }
+
+ public void setFooterHeight(int height) {
+ mFooterHeight = height;
+ }
+
+ /**
+ * Calculate the chooser target width to maximize space per item
+ *
+ * @param width The new row width to use for recalculation
+ * @return true if the view width has changed
+ */
+ public boolean calculateChooserTargetWidth(int width) {
+ if (width == 0) {
+ return false;
+ }
+
+ // Limit width to the maximum width of the chooser activity
+ int maxWidth = mChooserWidthPixels;
+ width = Math.min(maxWidth, width);
+
+ int newWidth = width / mMaxTargetsPerRow;
+ if (newWidth != mChooserTargetWidth) {
+ mChooserTargetWidth = newWidth;
+ return true;
+ }
+
+ return false;
+ }
+
+ public int getRowCount() {
+ return (int) (
+ getSystemRowCount()
+ + getProfileRowCount()
+ + getServiceTargetRowCount()
+ + getCallerAndRankedTargetRowCount()
+ + getAzLabelRowCount()
+ + Math.ceil(
+ (float) mChooserListAdapter.getAlphaTargetCount()
+ / mMaxTargetsPerRow)
+ );
+ }
+
+ /**
+ * Whether the "system" row of targets is displayed.
+ * This area includes the content preview (if present) and action row.
+ */
+ public int getSystemRowCount() {
+ // For the tabbed case we show the sticky content preview above the tabs,
+ // please refer to shouldShowStickyContentPreview
+ if (mChooserActivityDelegate.shouldShowTabs()) {
+ return 0;
+ }
+
+ if (!mShouldShowContentPreview) {
+ return 0;
+ }
+
+ if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) {
+ return 0;
+ }
+
+ return 1;
+ }
+
+ public int getProfileRowCount() {
+ if (mChooserActivityDelegate.shouldShowTabs()) {
+ return 0;
+ }
+ return mChooserListAdapter.getOtherProfile() == null ? 0 : 1;
+ }
+
+ public int getFooterRowCount() {
+ return 1;
+ }
+
+ public int getCallerAndRankedTargetRowCount() {
+ return (int) Math.ceil(
+ ((float) mChooserListAdapter.getCallerTargetCount()
+ + mChooserListAdapter.getRankedTargetCount()) / mMaxTargetsPerRow);
+ }
+
+ // There can be at most one row in the listview, that is internally
+ // a ViewGroup with 2 rows
+ public int getServiceTargetRowCount() {
+ if (mShouldShowContentPreview && !ActivityManager.isLowRamDeviceStatic()) {
+ return 1;
+ }
+ return 0;
+ }
+
+ public int getAzLabelRowCount() {
+ // Only show a label if the a-z list is showing
+ return (mShowAzLabelIfPoss && mChooserListAdapter.getAlphaTargetCount() > 0) ? 1 : 0;
+ }
+
+ @Override
+ public int getItemCount() {
+ return (int) (
+ getSystemRowCount()
+ + getProfileRowCount()
+ + getServiceTargetRowCount()
+ + getCallerAndRankedTargetRowCount()
+ + getAzLabelRowCount()
+ + mChooserListAdapter.getAlphaTargetCount()
+ + getFooterRowCount()
+ );
+ }
+
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ switch (viewType) {
+ case VIEW_TYPE_CONTENT_PREVIEW:
+ return new ItemViewHolder(
+ mChooserActivityDelegate.buildContentPreview(parent),
+ viewType,
+ null,
+ null);
+ case VIEW_TYPE_PROFILE:
+ return new ItemViewHolder(
+ createProfileView(parent),
+ viewType,
+ null,
+ null);
+ case VIEW_TYPE_AZ_LABEL:
+ return new ItemViewHolder(
+ createAzLabelView(parent),
+ viewType,
+ null,
+ null);
+ case VIEW_TYPE_NORMAL:
+ return new ItemViewHolder(
+ mChooserListAdapter.createView(parent),
+ viewType,
+ mChooserActivityDelegate::onTargetSelected,
+ mChooserActivityDelegate::onTargetLongPressed);
+ case VIEW_TYPE_DIRECT_SHARE:
+ case VIEW_TYPE_CALLER_AND_RANK:
+ return createItemGroupViewHolder(viewType, parent);
+ case VIEW_TYPE_FOOTER:
+ Space sp = new Space(parent.getContext());
+ sp.setLayoutParams(new RecyclerView.LayoutParams(
+ LayoutParams.MATCH_PARENT, mFooterHeight));
+ return new FooterViewHolder(sp, viewType);
+ default:
+ // Since we catch all possible viewTypes above, no chance this is being called.
+ return null;
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
+ int viewType = ((ViewHolderBase) holder).getViewType();
+ switch (viewType) {
+ case VIEW_TYPE_DIRECT_SHARE:
+ case VIEW_TYPE_CALLER_AND_RANK:
+ bindItemGroupViewHolder(position, (ItemGroupViewHolder) holder);
+ break;
+ case VIEW_TYPE_NORMAL:
+ bindItemViewHolder(position, (ItemViewHolder) holder);
+ break;
+ default:
+ }
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ int count;
+
+ int countSum = (count = getSystemRowCount());
+ if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW;
+
+ countSum += (count = getProfileRowCount());
+ if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE;
+
+ countSum += (count = getServiceTargetRowCount());
+ if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE;
+
+ countSum += (count = getCallerAndRankedTargetRowCount());
+ if (count > 0 && position < countSum) return VIEW_TYPE_CALLER_AND_RANK;
+
+ countSum += (count = getAzLabelRowCount());
+ if (count > 0 && position < countSum) return VIEW_TYPE_AZ_LABEL;
+
+ if (position == getItemCount() - 1) return VIEW_TYPE_FOOTER;
+
+ return VIEW_TYPE_NORMAL;
+ }
+
+ public int getTargetType(int position) {
+ return mChooserListAdapter.getPositionTargetType(getListPosition(position));
+ }
+
+ private View createProfileView(ViewGroup parent) {
+ View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false);
+ mChooserActivityDelegate.updateProfileViewButton(profileRow);
+ return profileRow;
+ }
+
+ private View createAzLabelView(ViewGroup parent) {
+ return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false);
+ }
+
+ private ItemGroupViewHolder loadViewsIntoGroup(ItemGroupViewHolder holder) {
+ final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ final int exactSpec = MeasureSpec.makeMeasureSpec(mChooserTargetWidth, MeasureSpec.EXACTLY);
+ int columnCount = holder.getColumnCount();
+
+ final boolean isDirectShare = holder instanceof DirectShareViewHolder;
+
+ for (int i = 0; i < columnCount; i++) {
+ final View v = mChooserListAdapter.createView(holder.getRowByIndex(i));
+ final int column = i;
+ v.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mChooserActivityDelegate.onTargetSelected(holder.getItemIndex(column));
+ }
+ });
+
+ // Show menu for both direct share and app share targets after long click.
+ v.setOnLongClickListener(v1 -> {
+ mChooserActivityDelegate.onTargetLongPressed(holder.getItemIndex(column));
+ return true;
+ });
+
+ holder.addView(i, v);
+
+ // Force Direct Share to be 2 lines and auto-wrap to second line via hoz scroll =
+ // false. TextView#setHorizontallyScrolling must be reset after #setLines. Must be
+ // done before measuring.
+ if (isDirectShare) {
+ final ViewHolder vh = (ViewHolder) v.getTag();
+ vh.text.setLines(2);
+ vh.text.setHorizontallyScrolling(false);
+ vh.text2.setVisibility(View.GONE);
+ }
+
+ // Force height to be a given so we don't have visual disruption during scaling.
+ v.measure(exactSpec, spec);
+ setViewBounds(v, v.getMeasuredWidth(), v.getMeasuredHeight());
+ }
+
+ final ViewGroup viewGroup = holder.getViewGroup();
+
+ // Pre-measure and fix height so we can scale later.
+ holder.measure();
+ setViewBounds(viewGroup, LayoutParams.MATCH_PARENT, holder.getMeasuredRowHeight());
+
+ if (isDirectShare) {
+ DirectShareViewHolder dsvh = (DirectShareViewHolder) holder;
+ setViewBounds(dsvh.getRow(0), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight());
+ setViewBounds(dsvh.getRow(1), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight());
+ }
+
+ viewGroup.setTag(holder);
+ return holder;
+ }
+
+ private void setViewBounds(View view, int widthPx, int heightPx) {
+ LayoutParams lp = view.getLayoutParams();
+ if (lp == null) {
+ lp = new LayoutParams(widthPx, heightPx);
+ view.setLayoutParams(lp);
+ } else {
+ lp.height = heightPx;
+ lp.width = widthPx;
+ }
+ }
+
+ ItemGroupViewHolder createItemGroupViewHolder(int viewType, ViewGroup parent) {
+ if (viewType == VIEW_TYPE_DIRECT_SHARE) {
+ ViewGroup parentGroup = (ViewGroup) mLayoutInflater.inflate(
+ R.layout.chooser_row_direct_share, parent, false);
+ ViewGroup row1 = (ViewGroup) mLayoutInflater.inflate(
+ R.layout.chooser_row, parentGroup, false);
+ ViewGroup row2 = (ViewGroup) mLayoutInflater.inflate(
+ R.layout.chooser_row, parentGroup, false);
+ parentGroup.addView(row1);
+ parentGroup.addView(row2);
+
+ mDirectShareViewHolder = new DirectShareViewHolder(parentGroup,
+ Lists.newArrayList(row1, row2), mMaxTargetsPerRow, viewType,
+ mChooserActivityDelegate::getValidTargetCount);
+ loadViewsIntoGroup(mDirectShareViewHolder);
+
+ return mDirectShareViewHolder;
+ } else {
+ ViewGroup row = (ViewGroup) mLayoutInflater.inflate(
+ R.layout.chooser_row, parent, false);
+ ItemGroupViewHolder holder =
+ new SingleRowViewHolder(row, mMaxTargetsPerRow, viewType);
+ loadViewsIntoGroup(holder);
+
+ return holder;
+ }
+ }
+
+ /**
+ * Need to merge CALLER + ranked STANDARD into a single row and prevent a separator from
+ * showing on top of the AZ list if the AZ label is visible. All other types are placed into
+ * their own row as determined by their target type, and dividers are added in the list to
+ * separate each type.
+ */
+ int getRowType(int rowPosition) {
+ // Merge caller and ranked standard into a single row
+ int positionType = mChooserListAdapter.getPositionTargetType(rowPosition);
+ if (positionType == ChooserListAdapter.TARGET_CALLER) {
+ return ChooserListAdapter.TARGET_STANDARD;
+ }
+
+ // If an A-Z label is shown, prevent a separator from appearing by making the A-Z
+ // row type the same as the suggestion row type
+ if (getAzLabelRowCount() > 0 && positionType == ChooserListAdapter.TARGET_STANDARD_AZ) {
+ return ChooserListAdapter.TARGET_STANDARD;
+ }
+
+ return positionType;
+ }
+
+ void bindItemViewHolder(int position, ItemViewHolder holder) {
+ View v = holder.itemView;
+ int listPosition = getListPosition(position);
+ holder.setListPosition(listPosition);
+ mChooserListAdapter.bindView(listPosition, v);
+ }
+
+ void bindItemGroupViewHolder(int position, ItemGroupViewHolder holder) {
+ final ViewGroup viewGroup = (ViewGroup) holder.itemView;
+ int start = getListPosition(position);
+ int startType = getRowType(start);
+
+ int columnCount = holder.getColumnCount();
+ int end = start + columnCount - 1;
+ while (getRowType(end) != startType && end >= start) {
+ end--;
+ }
+
+ if (end == start && mChooserListAdapter.getItem(start).isEmptyTargetInfo()) {
+ final TextView textView = viewGroup.findViewById(
+ com.android.internal.R.id.chooser_row_text_option);
+
+ if (textView.getVisibility() != View.VISIBLE) {
+ textView.setAlpha(0.0f);
+ textView.setVisibility(View.VISIBLE);
+ textView.setText(R.string.chooser_no_direct_share_targets);
+
+ ValueAnimator fadeAnim = ObjectAnimator.ofFloat(textView, "alpha", 0.0f, 1.0f);
+ fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f));
+
+ textView.setTranslationY(mChooserRowTextOptionTranslatePixelSize);
+ ValueAnimator translateAnim =
+ ObjectAnimator.ofFloat(textView, "translationY", 0.0f);
+ translateAnim.setInterpolator(new DecelerateInterpolator(1.0f));
+
+ AnimatorSet animSet = new AnimatorSet();
+ animSet.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS);
+ animSet.setStartDelay(NO_DIRECT_SHARE_ANIM_IN_MILLIS);
+ animSet.playTogether(fadeAnim, translateAnim);
+ animSet.start();
+ }
+ }
+
+ for (int i = 0; i < columnCount; i++) {
+ final View v = holder.getView(i);
+
+ if (start + i <= end) {
+ holder.setViewVisibility(i, View.VISIBLE);
+ holder.setItemIndex(i, start + i);
+ mChooserListAdapter.bindView(holder.getItemIndex(i), v);
+ } else {
+ holder.setViewVisibility(i, View.INVISIBLE);
+ }
+ }
+ }
+
+ int getListPosition(int position) {
+ position -= getSystemRowCount() + getProfileRowCount();
+
+ final int serviceCount = mChooserListAdapter.getServiceTargetCount();
+ final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow);
+ if (position < serviceRows) {
+ return position * mMaxTargetsPerRow;
+ }
+
+ position -= serviceRows;
+
+ final int callerAndRankedCount =
+ mChooserListAdapter.getCallerTargetCount()
+ + mChooserListAdapter.getRankedTargetCount();
+ final int callerAndRankedRows = getCallerAndRankedTargetRowCount();
+ if (position < callerAndRankedRows) {
+ return serviceCount + position * mMaxTargetsPerRow;
+ }
+
+ position -= getAzLabelRowCount() + callerAndRankedRows;
+
+ return callerAndRankedCount + serviceCount + position;
+ }
+
+ public void handleScroll(View v, int y, int oldy) {
+ boolean canExpandDirectShare = canExpandDirectShare();
+ if (mDirectShareViewHolder != null && canExpandDirectShare) {
+ mChooserActivityDelegate.handleScrollToExpandDirectShare(
+ mDirectShareViewHolder, y, oldy);
+ }
+ }
+
+ /** Only expand direct share area if there is a minimum number of targets. */
+ private boolean canExpandDirectShare() {
+ // Do not enable until we have confirmed more apps are using sharing shortcuts
+ // Check git history for enablement logic
+ return false;
+ }
+
+ public ChooserListAdapter getListAdapter() {
+ return mChooserListAdapter;
+ }
+
+ public boolean shouldCellSpan(int position) {
+ return getItemViewType(position) == VIEW_TYPE_NORMAL;
+ }
+
+ public void updateDirectShareExpansion() {
+ if (mDirectShareViewHolder == null || !canExpandDirectShare()) {
+ return;
+ }
+ mChooserActivityDelegate.updateDirectShareExpansion(mDirectShareViewHolder);
+ }
+}
diff --git a/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java
new file mode 100644
index 0000000..316c9f0
--- /dev/null
+++ b/java/src/com/android/intentresolver/grid/DirectShareViewHolder.java
@@ -0,0 +1,197 @@
+/*
+ * 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.grid;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.ViewGroup;
+import android.view.animation.AccelerateInterpolator;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.intentresolver.ChooserActivity;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Supplier;
+
+/** Holder for direct share targets in the {@link ChooserGridAdapter}. */
+public class DirectShareViewHolder extends ItemGroupViewHolder {
+ private final ViewGroup mParent;
+ private final List<ViewGroup> mRows;
+ private int mCellCountPerRow;
+
+ private boolean mHideDirectShareExpansion = false;
+ private int mDirectShareMinHeight = 0;
+ private int mDirectShareCurrHeight = 0;
+ private int mDirectShareMaxHeight = 0;
+
+ private final boolean[] mCellVisibility;
+
+ private final Supplier<Integer> mDeferredTargetCountSupplier;
+
+ public DirectShareViewHolder(
+ ViewGroup parent,
+ List<ViewGroup> rows,
+ int cellCountPerRow,
+ int viewType,
+ Supplier<Integer> deferredTargetCountSupplier) {
+ super(rows.size() * cellCountPerRow, parent, viewType);
+
+ this.mParent = parent;
+ this.mRows = rows;
+ this.mCellCountPerRow = cellCountPerRow;
+ this.mCellVisibility = new boolean[rows.size() * cellCountPerRow];
+ Arrays.fill(mCellVisibility, true);
+ this.mDeferredTargetCountSupplier = deferredTargetCountSupplier;
+ }
+
+ public ViewGroup addView(int index, View v) {
+ ViewGroup row = getRowByIndex(index);
+ row.addView(v);
+ mCells[index] = v;
+
+ return row;
+ }
+
+ public ViewGroup getViewGroup() {
+ return mParent;
+ }
+
+ public ViewGroup getRowByIndex(int index) {
+ return mRows.get(index / mCellCountPerRow);
+ }
+
+ public ViewGroup getRow(int rowNumber) {
+ return mRows.get(rowNumber);
+ }
+
+ public void measure() {
+ final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ getRow(0).measure(spec, spec);
+ getRow(1).measure(spec, spec);
+
+ mDirectShareMinHeight = getRow(0).getMeasuredHeight();
+ mDirectShareCurrHeight = (mDirectShareCurrHeight > 0)
+ ? mDirectShareCurrHeight : mDirectShareMinHeight;
+ mDirectShareMaxHeight = 2 * mDirectShareMinHeight;
+ }
+
+ public int getMeasuredRowHeight() {
+ return mDirectShareCurrHeight;
+ }
+
+ public int getMinRowHeight() {
+ return mDirectShareMinHeight;
+ }
+
+ public void setViewVisibility(int i, int visibility) {
+ final View v = getView(i);
+ if (visibility == View.VISIBLE) {
+ mCellVisibility[i] = true;
+ v.setVisibility(visibility);
+ v.setAlpha(1.0f);
+ } else if (visibility == View.INVISIBLE && mCellVisibility[i]) {
+ mCellVisibility[i] = false;
+
+ ValueAnimator fadeAnim = ObjectAnimator.ofFloat(v, "alpha", 1.0f, 0f);
+ fadeAnim.setDuration(ChooserGridAdapter.NO_DIRECT_SHARE_ANIM_IN_MILLIS);
+ fadeAnim.setInterpolator(new AccelerateInterpolator(1.0f));
+ fadeAnim.addListener(new AnimatorListenerAdapter() {
+ public void onAnimationEnd(Animator animation) {
+ v.setVisibility(View.INVISIBLE);
+ }
+ });
+ fadeAnim.start();
+ }
+ }
+
+ public void handleScroll(RecyclerView view, int y, int oldy, int maxTargetsPerRow) {
+ // only exit early if fully collapsed, otherwise onListRebuilt() with shifting
+ // targets can lock us into an expanded mode
+ boolean notExpanded = mDirectShareCurrHeight == mDirectShareMinHeight;
+ if (notExpanded) {
+ if (mHideDirectShareExpansion) {
+ return;
+ }
+
+ // only expand if we have more than maxTargetsPerRow, and delay that decision
+ // until they start to scroll
+ final int validTargets = this.mDeferredTargetCountSupplier.get();
+ if (validTargets <= maxTargetsPerRow) {
+ mHideDirectShareExpansion = true;
+ return;
+ }
+ }
+
+ int yDiff = (int) ((oldy - y) * ChooserActivity.DIRECT_SHARE_EXPANSION_RATE);
+
+ int prevHeight = mDirectShareCurrHeight;
+ int newHeight = Math.min(prevHeight + yDiff, mDirectShareMaxHeight);
+ newHeight = Math.max(newHeight, mDirectShareMinHeight);
+ yDiff = newHeight - prevHeight;
+
+ updateDirectShareRowHeight(view, yDiff, newHeight);
+ }
+
+ public void expand(RecyclerView view) {
+ updateDirectShareRowHeight(
+ view, mDirectShareMaxHeight - mDirectShareCurrHeight, mDirectShareMaxHeight);
+ }
+
+ public void collapse(RecyclerView view) {
+ updateDirectShareRowHeight(
+ view, mDirectShareMinHeight - mDirectShareCurrHeight, mDirectShareMinHeight);
+ }
+
+ private void updateDirectShareRowHeight(RecyclerView view, int yDiff, int newHeight) {
+ if (view == null || view.getChildCount() == 0 || yDiff == 0) {
+ return;
+ }
+
+ // locate the item to expand, and offset the rows below that one
+ boolean foundExpansion = false;
+ for (int i = 0; i < view.getChildCount(); i++) {
+ View child = view.getChildAt(i);
+
+ if (foundExpansion) {
+ child.offsetTopAndBottom(yDiff);
+ } else {
+ if (child.getTag() != null && child.getTag() instanceof DirectShareViewHolder) {
+ int widthSpec = MeasureSpec.makeMeasureSpec(child.getWidth(),
+ MeasureSpec.EXACTLY);
+ int heightSpec = MeasureSpec.makeMeasureSpec(newHeight,
+ MeasureSpec.EXACTLY);
+ child.measure(widthSpec, heightSpec);
+ child.getLayoutParams().height = child.getMeasuredHeight();
+ child.layout(child.getLeft(), child.getTop(), child.getRight(),
+ child.getTop() + child.getMeasuredHeight());
+
+ foundExpansion = true;
+ }
+ }
+ }
+
+ if (foundExpansion) {
+ mDirectShareCurrHeight = newHeight;
+ }
+ }
+}
diff --git a/java/src/com/android/intentresolver/grid/FooterViewHolder.java b/java/src/com/android/intentresolver/grid/FooterViewHolder.java
new file mode 100644
index 0000000..0c94e3e
--- /dev/null
+++ b/java/src/com/android/intentresolver/grid/FooterViewHolder.java
@@ -0,0 +1,28 @@
+/*
+ * 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.grid;
+
+import android.view.View;
+
+/**
+ * A footer on the list, to support scrolling behavior below the navbar.
+ */
+public final class FooterViewHolder extends ViewHolderBase {
+ public FooterViewHolder(View itemView, int viewType) {
+ super(itemView, viewType);
+ }
+}
diff --git a/java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java b/java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java
new file mode 100644
index 0000000..5470506
--- /dev/null
+++ b/java/src/com/android/intentresolver/grid/ItemGroupViewHolder.java
@@ -0,0 +1,76 @@
+/*
+ * 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.grid;
+
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.ViewGroup;
+
+/**
+ * Used to bind types for group of items including:
+ * {@link ChooserGridAdapter#VIEW_TYPE_DIRECT_SHARE},
+ * and {@link ChooserGridAdapter#VIEW_TYPE_CALLER_AND_RANK}.
+ */
+public abstract class ItemGroupViewHolder extends ViewHolderBase {
+ protected int mMeasuredRowHeight;
+ private int[] mItemIndices;
+ protected final View[] mCells;
+ private final int mColumnCount;
+
+ public ItemGroupViewHolder(int cellCount, View itemView, int viewType) {
+ super(itemView, viewType);
+ this.mCells = new View[cellCount];
+ this.mItemIndices = new int[cellCount];
+ this.mColumnCount = cellCount;
+ }
+
+ public abstract ViewGroup addView(int index, View v);
+
+ public abstract ViewGroup getViewGroup();
+
+ public abstract ViewGroup getRowByIndex(int index);
+
+ public abstract ViewGroup getRow(int rowNumber);
+
+ public abstract void setViewVisibility(int i, int visibility);
+
+ public int getColumnCount() {
+ return mColumnCount;
+ }
+
+ public void measure() {
+ final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ getViewGroup().measure(spec, spec);
+ mMeasuredRowHeight = getViewGroup().getMeasuredHeight();
+ }
+
+ public int getMeasuredRowHeight() {
+ return mMeasuredRowHeight;
+ }
+
+ public void setItemIndex(int itemIndex, int listIndex) {
+ mItemIndices[itemIndex] = listIndex;
+ }
+
+ public int getItemIndex(int itemIndex) {
+ return mItemIndices[itemIndex];
+ }
+
+ public View getView(int index) {
+ return mCells[index];
+ }
+}
diff --git a/java/src/com/android/intentresolver/grid/ItemViewHolder.java b/java/src/com/android/intentresolver/grid/ItemViewHolder.java
new file mode 100644
index 0000000..2ec56b1
--- /dev/null
+++ b/java/src/com/android/intentresolver/grid/ItemViewHolder.java
@@ -0,0 +1,63 @@
+/*
+ * 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.grid;
+
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import com.android.intentresolver.ChooserListAdapter;
+import com.android.intentresolver.ResolverListAdapter;
+
+import java.util.function.Consumer;
+
+/**
+ * Used to bind types of individual item including
+ * {@link ChooserGridAdapter#VIEW_TYPE_NORMAL},
+ * {@link ChooserGridAdapter#VIEW_TYPE_CONTENT_PREVIEW},
+ * {@link ChooserGridAdapter#VIEW_TYPE_PROFILE},
+ * and {@link ChooserGridAdapter#VIEW_TYPE_AZ_LABEL}.
+ */
+public final class ItemViewHolder extends ViewHolderBase {
+ private final ResolverListAdapter.ViewHolder mWrappedViewHolder;
+
+ private int mListPosition = ChooserListAdapter.NO_POSITION;
+
+ public ItemViewHolder(
+ View itemView,
+ int viewType,
+ @Nullable Consumer<Integer> onClick,
+ @Nullable Consumer<Integer> onLongClick) {
+ super(itemView, viewType);
+ mWrappedViewHolder = new ResolverListAdapter.ViewHolder(itemView);
+
+ if (onClick != null) {
+ itemView.setOnClickListener(v -> onClick.accept(mListPosition));
+ }
+
+ if (onLongClick != null) {
+ itemView.setOnLongClickListener(v -> {
+ onLongClick.accept(mListPosition);
+ return true;
+ });
+ }
+ }
+
+ public void setListPosition(int listPosition) {
+ mListPosition = listPosition;
+ }
+}
diff --git a/java/src/com/android/intentresolver/grid/SingleRowViewHolder.java b/java/src/com/android/intentresolver/grid/SingleRowViewHolder.java
new file mode 100644
index 0000000..a72da7a
--- /dev/null
+++ b/java/src/com/android/intentresolver/grid/SingleRowViewHolder.java
@@ -0,0 +1,73 @@
+/*
+ * 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.grid;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+/** Holder for a group of items displayed in a single row of the {@link ChooserGridAdapter}. */
+public final class SingleRowViewHolder extends ItemGroupViewHolder {
+ private final ViewGroup mRow;
+
+ public SingleRowViewHolder(ViewGroup row, int cellCount, int viewType) {
+ super(cellCount, row, viewType);
+
+ this.mRow = row;
+ }
+
+ /** Get the group of all views in this holder. */
+ public ViewGroup getViewGroup() {
+ return mRow;
+ }
+
+ /**
+ * Get the group of views for the row containing the specified cell index.
+ * TODO: unclear if that's what this `index` meant. It doesn't matter for our "single row"
+ * holders, and it doesn't look like this is an override from some other interface; maybe we can
+ * just remove?
+ */
+ public ViewGroup getRowByIndex(int index) {
+ return mRow;
+ }
+
+ /** Get the group of views for the specified {@code rowNumber}, if any. */
+ public ViewGroup getRow(int rowNumber) {
+ if (rowNumber == 0) {
+ return mRow;
+ }
+ return null;
+ }
+
+ /**
+ * @param index the index of the cell to add the view into.
+ * @param v the view to add into the cell.
+ */
+ public ViewGroup addView(int index, View v) {
+ mRow.addView(v);
+ mCells[index] = v;
+
+ return mRow;
+ }
+
+ /**
+ * @param i the index of the cell containing the view to modify.
+ * @param visibility the new visibility to set on the view with the specified index.
+ */
+ public void setViewVisibility(int i, int visibility) {
+ getView(i).setVisibility(visibility);
+ }
+}
diff --git a/java/src/com/android/intentresolver/grid/ViewHolderBase.java b/java/src/com/android/intentresolver/grid/ViewHolderBase.java
new file mode 100644
index 0000000..78e9104
--- /dev/null
+++ b/java/src/com/android/intentresolver/grid/ViewHolderBase.java
@@ -0,0 +1,35 @@
+/*
+ * 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.grid;
+
+import android.view.View;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+/** Base class for all {@link RecyclerView.ViewHolder} types in the {@link ChooserGridAdapter}. */
+public abstract class ViewHolderBase extends RecyclerView.ViewHolder {
+ private int mViewType;
+
+ ViewHolderBase(View itemView, int viewType) {
+ super(itemView);
+ this.mViewType = viewType;
+ }
+
+ public int getViewType() {
+ return mViewType;
+ }
+}
diff --git a/java/src/com/android/intentresolver/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
similarity index 91%
rename from java/src/com/android/intentresolver/AbstractResolverComparator.java
rename to java/src/com/android/intentresolver/model/AbstractResolverComparator.java
index 6f80287..271c6f9 100644
--- a/java/src/com/android/intentresolver/AbstractResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.android.intentresolver;
+package com.android.intentresolver.model;
import android.app.usage.UsageStatsManager;
import android.content.ComponentName;
@@ -29,6 +29,8 @@
import android.os.UserHandle;
import android.util.Log;
+import com.android.intentresolver.ChooserActivityLogger;
+import com.android.intentresolver.ResolverActivity;
import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
import java.text.Collator;
@@ -47,7 +49,7 @@
private static final boolean DEBUG = true;
private static final String TAG = "AbstractResolverComp";
- protected AfterCompute mAfterCompute;
+ protected Runnable mAfterCompute;
protected final PackageManager mPm;
protected final UsageStatsManager mUsm;
protected String[] mAnnotations;
@@ -129,15 +131,7 @@
}
}
- /**
- * Callback to be called when {@link #compute(List)} finishes. This signals to stop waiting.
- */
- interface AfterCompute {
-
- void afterCompute();
- }
-
- void setCallBack(AfterCompute afterCompute) {
+ public void setCallBack(Runnable afterCompute) {
mAfterCompute = afterCompute;
}
@@ -150,9 +144,9 @@
}
protected final void afterCompute() {
- final AfterCompute afterCompute = mAfterCompute;
+ final Runnable afterCompute = mAfterCompute;
if (afterCompute != null) {
- afterCompute.afterCompute();
+ afterCompute.run();
}
}
@@ -161,11 +155,6 @@
final ResolveInfo lhs = lhsp.getResolveInfoAt(0);
final ResolveInfo rhs = rhsp.getResolveInfoAt(0);
- final boolean lFixedAtTop = lhsp.isFixedAtTop();
- final boolean rFixedAtTop = rhsp.isFixedAtTop();
- if (lFixedAtTop && !rFixedAtTop) return -1;
- if (!lFixedAtTop && rFixedAtTop) return 1;
-
// We want to put the one targeted to another user at the end of the dialog.
if (lhs.targetUserId != UserHandle.USER_CURRENT) {
return rhs.targetUserId != UserHandle.USER_CURRENT ? 0 : 1;
@@ -214,7 +203,7 @@
* ResolvedComponentInfo} by {@link ComponentName}. {@link #beforeCompute()} will be called
* before doing any computing.
*/
- final void compute(List<ResolvedComponentInfo> targets) {
+ public final void compute(List<ResolvedComponentInfo> targets) {
beforeCompute();
doCompute(targets);
}
@@ -226,7 +215,7 @@
* Returns the score that was calculated for the corresponding {@link ResolvedComponentInfo}
* when {@link #compute(List)} was called before this.
*/
- abstract float getScore(ComponentName name);
+ public abstract float getScore(ComponentName name);
/** Handles result message sent to mHandler. */
abstract void handleResultMessage(Message message);
@@ -234,7 +223,7 @@
/**
* Reports to UsageStats what was chosen.
*/
- final void updateChooserCounts(String packageName, int userId, String action) {
+ public final void updateChooserCounts(String packageName, int userId, String action) {
if (mUsm != null) {
mUsm.reportChooserSelection(packageName, userId, mContentType, mAnnotations, action);
}
@@ -248,7 +237,7 @@
*
* @param componentName the component that the user clicked
*/
- void updateModel(ComponentName componentName) {
+ public void updateModel(ComponentName componentName) {
}
/** Called before {@link #doCompute(List)}. Sets up 500ms timeout. */
@@ -266,7 +255,7 @@
* this call needs to happen at a different time during destroy, the method should be
* overridden.
*/
- void destroy() {
+ public void destroy() {
mHandler.removeMessages(RANKER_SERVICE_RESULT);
mHandler.removeMessages(RANKER_RESULT_TIMEOUT);
afterCompute();
diff --git a/java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
similarity index 96%
rename from java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java
rename to java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
index 9b9fc1c..c6bb2b8 100644
--- a/java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.android.intentresolver;
+package com.android.intentresolver.model;
import static android.app.prediction.AppTargetEvent.ACTION_LAUNCH;
@@ -31,6 +31,7 @@
import android.os.UserHandle;
import android.util.Log;
+import com.android.intentresolver.ChooserActivityLogger;
import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
import java.util.ArrayList;
@@ -45,7 +46,7 @@
* disabled by returning an empty sorted target list, {@link AppPredictionServiceResolverComparator}
* will fallback to using a {@link ResolverRankerServiceResolverComparator}.
*/
-class AppPredictionServiceResolverComparator extends AbstractResolverComparator {
+public class AppPredictionServiceResolverComparator extends AbstractResolverComparator {
private static final String TAG = "APSResolverComparator";
@@ -62,7 +63,7 @@
private ResolverRankerServiceResolverComparator mResolverRankerService;
private AppPredictionServiceComparatorModel mComparatorModel;
- AppPredictionServiceResolverComparator(
+ public AppPredictionServiceResolverComparator(
Context context,
Intent intent,
String referrerPackage,
@@ -166,17 +167,17 @@
}
@Override
- float getScore(ComponentName name) {
+ public float getScore(ComponentName name) {
return mComparatorModel.getScore(name);
}
@Override
- void updateModel(ComponentName componentName) {
+ public void updateModel(ComponentName componentName) {
mComparatorModel.notifyOnTargetSelected(componentName);
}
@Override
- void destroy() {
+ public void destroy() {
if (mResolverRankerService != null) {
mResolverRankerService.destroy();
mResolverRankerService = null;
diff --git a/java/src/com/android/intentresolver/ResolverComparatorModel.java b/java/src/com/android/intentresolver/model/ResolverComparatorModel.java
similarity index 97%
rename from java/src/com/android/intentresolver/ResolverComparatorModel.java
rename to java/src/com/android/intentresolver/model/ResolverComparatorModel.java
index 79160c8..3616a85 100644
--- a/java/src/com/android/intentresolver/ResolverComparatorModel.java
+++ b/java/src/com/android/intentresolver/model/ResolverComparatorModel.java
@@ -14,13 +14,12 @@
* limitations under the License.
*/
-package com.android.intentresolver;
+package com.android.intentresolver.model;
import android.content.ComponentName;
import android.content.pm.ResolveInfo;
import java.util.Comparator;
-import java.util.List;
/**
* A ranking model for resolver targets, providing ordering and (optionally) numerical scoring.
diff --git a/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
similarity index 96%
rename from java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java
rename to java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
index be3e6f1..4382f10 100644
--- a/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java
+++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java
@@ -15,7 +15,7 @@
*/
-package com.android.intentresolver;
+package com.android.intentresolver.model;
import android.app.usage.UsageStats;
import android.content.ComponentName;
@@ -37,8 +37,8 @@
import android.service.resolver.ResolverTarget;
import android.util.Log;
+import com.android.intentresolver.ChooserActivityLogger;
import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo;
-
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
@@ -54,7 +54,7 @@
/**
* Ranks and compares packages based on usage stats and uses the {@link ResolverRankerService}.
*/
-class ResolverRankerServiceResolverComparator extends AbstractResolverComparator {
+public class ResolverRankerServiceResolverComparator extends AbstractResolverComparator {
private static final String TAG = "RRSResolverComparator";
private static final boolean DEBUG = false;
@@ -87,7 +87,7 @@
private ResolverRankerServiceComparatorModel mComparatorModel;
public ResolverRankerServiceResolverComparator(Context context, Intent intent,
- String referrerPackage, AfterCompute afterCompute,
+ String referrerPackage, Runnable afterCompute,
ChooserActivityLogger chooserActivityLogger) {
super(context, intent);
mCollator = Collator.getInstance(context.getResources().getConfiguration().locale);
@@ -191,9 +191,9 @@
if (mAction == null) {
Log.d(TAG, "Action type is null");
} else {
- Log.d(TAG, "Chooser Count of " + mAction + ":" +
- target.name.getPackageName() + " is " +
- Float.toString(chooserScore));
+ Log.d(TAG, "Chooser Count of " + mAction + ":"
+ + target.name.getPackageName() + " is "
+ + Float.toString(chooserScore));
}
}
resolverTarget.setChooserScore(chooserScore);
@@ -333,7 +333,7 @@
private class ResolverRankerServiceConnection implements ServiceConnection {
private final CountDownLatch mConnectSignal;
- public ResolverRankerServiceConnection(CountDownLatch connectSignal) {
+ ResolverRankerServiceConnection(CountDownLatch connectSignal) {
mConnectSignal = connectSignal;
}
@@ -424,8 +424,10 @@
// adds select prob as the default values, according to a pre-trained Logistic Regression model.
private void addDefaultSelectProbability(ResolverTarget target) {
- float sum = 2.5543f * target.getLaunchScore() + 2.8412f * target.getTimeSpentScore() +
- 0.269f * target.getRecencyScore() + 4.2222f * target.getChooserScore();
+ float sum = (2.5543f * target.getLaunchScore())
+ + (2.8412f * target.getTimeSpentScore())
+ + (0.269f * target.getRecencyScore())
+ + (4.2222f * target.getChooserScore());
target.setSelectProbability((float) (1.0 / (1.0 + Math.exp(1.6568f - sum))));
}
@@ -440,8 +442,8 @@
static boolean isPersistentProcess(ResolvedComponentInfo rci) {
if (rci != null && rci.getCount() > 0) {
- return (rci.getResolveInfoAt(0).activityInfo.applicationInfo.flags &
- ApplicationInfo.FLAG_PERSISTENT) != 0;
+ int flags = rci.getResolveInfoAt(0).activityInfo.applicationInfo.flags;
+ return (flags & ApplicationInfo.FLAG_PERSISTENT) != 0;
}
return false;
}
diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
new file mode 100644
index 0000000..82f40b9
--- /dev/null
+++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.prediction.AppPredictionContext
+import android.app.prediction.AppPredictionManager
+import android.app.prediction.AppPredictor
+import android.content.Context
+import android.content.IntentFilter
+import android.os.Bundle
+import android.os.UserHandle
+
+// TODO(b/123088566) Share these in a better way.
+private const val APP_PREDICTION_SHARE_UI_SURFACE = "share"
+private const val APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT = 20
+private const val APP_PREDICTION_INTENT_FILTER_KEY = "intent_filter"
+private const val SHARED_TEXT_KEY = "shared_text"
+
+/**
+ * A factory to create an AppPredictor instance for a profile, if available.
+ * @param context, application context
+ * @param sharedText, a shared text associated with the Chooser's target intent
+ * (see [android.content.Intent.EXTRA_TEXT]).
+ * Will be mapped to app predictor's "shared_text" parameter.
+ * @param targetIntentFilter, an IntentFilter to match direct share targets against.
+ * Will be mapped app predictor's "intent_filter" parameter.
+ */
+class AppPredictorFactory(
+ private val context: Context,
+ private val sharedText: String?,
+ private val targetIntentFilter: IntentFilter?
+) {
+ private val mIsComponentAvailable =
+ context.packageManager.appPredictionServicePackageName != null
+
+ /**
+ * Creates an AppPredictor instance for a profile or `null` if app predictor is not available.
+ */
+ fun create(userHandle: UserHandle): AppPredictor? {
+ if (!mIsComponentAvailable) return null
+ val contextAsUser = context.createContextAsUser(userHandle, 0 /* flags */)
+ val extras = Bundle().apply {
+ putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter)
+ putString(SHARED_TEXT_KEY, sharedText)
+ }
+ val appPredictionContext = AppPredictionContext.Builder(contextAsUser)
+ .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE)
+ .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT)
+ .setExtras(extras)
+ .build()
+ return contextAsUser.getSystemService(AppPredictionManager::class.java)
+ ?.createAppPredictionSession(appPredictionContext)
+ }
+}
diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java
new file mode 100644
index 0000000..1cfa2c8
--- /dev/null
+++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.java
@@ -0,0 +1,426 @@
+/*
+ * 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/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java
new file mode 100644
index 0000000..a37d655
--- /dev/null
+++ b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java
@@ -0,0 +1,109 @@
+/*
+ * 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.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.prediction.AppTarget;
+import android.content.Intent;
+import android.content.pm.ShortcutInfo;
+import android.content.pm.ShortcutManager;
+import android.os.Bundle;
+import android.service.chooser.ChooserTarget;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+class ShortcutToChooserTargetConverter {
+
+ /**
+ * Converts a list of ShareShortcutInfos to ChooserTargets.
+ * @param matchingShortcuts List of shortcuts, all from the same package, that match the current
+ * share intent filter.
+ * @param allShortcuts List of all the shortcuts from all the packages on the device that are
+ * returned for the current sharing action.
+ * @param allAppTargets List of AppTargets. Null if the results are not from prediction service.
+ * @param directShareAppTargetCache An optional map to store mapping for the new ChooserTarget
+ * instances back to original allAppTargets.
+ * @param directShareShortcutInfoCache An optional map to store mapping from the new
+ * ChooserTarget instances back to the original matchingShortcuts' {@code getShortcutInfo()}
+ * @return A list of ChooserTargets sorted by score in descending order.
+ */
+ @NonNull
+ public List<ChooserTarget> convertToChooserTarget(
+ @NonNull List<ShortcutManager.ShareShortcutInfo> matchingShortcuts,
+ @NonNull List<ShortcutManager.ShareShortcutInfo> allShortcuts,
+ @Nullable List<AppTarget> allAppTargets,
+ @Nullable Map<ChooserTarget, AppTarget> directShareAppTargetCache,
+ @Nullable Map<ChooserTarget, ShortcutInfo> directShareShortcutInfoCache) {
+ // If |appTargets| is not null, results are from AppPredictionService and already sorted.
+ final boolean isFromAppPredictor = allAppTargets != null;
+ // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted
+ // list instead of the actual rank value when converting a rank to a score.
+ List<Integer> scoreList = new ArrayList<>();
+ if (!isFromAppPredictor) {
+ for (int i = 0; i < matchingShortcuts.size(); i++) {
+ int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank();
+ if (!scoreList.contains(shortcutRank)) {
+ scoreList.add(shortcutRank);
+ }
+ }
+ Collections.sort(scoreList);
+ }
+
+ List<ChooserTarget> chooserTargetList = new ArrayList<>(matchingShortcuts.size());
+ for (int i = 0; i < matchingShortcuts.size(); i++) {
+ ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo();
+ int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i));
+
+ float score;
+ if (isFromAppPredictor) {
+ // Incoming results are ordered. Create a score based on index in the original list.
+ score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f);
+ } else {
+ // Create a score based on the rank of the shortcut.
+ int rankIndex = scoreList.indexOf(shortcutInfo.getRank());
+ score = Math.max(1.0f - (0.01f * rankIndex), 0.0f);
+ }
+
+ Bundle extras = new Bundle();
+ extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId());
+
+ ChooserTarget chooserTarget = new ChooserTarget(
+ shortcutInfo.getLabel(),
+ null, // Icon will be loaded later if this target is selected to be shown.
+ score, matchingShortcuts.get(i).getTargetComponent().clone(), extras);
+
+ chooserTargetList.add(chooserTarget);
+ if (directShareAppTargetCache != null && allAppTargets != null) {
+ directShareAppTargetCache.put(chooserTarget,
+ allAppTargets.get(indexInAllShortcuts));
+ }
+ if (directShareShortcutInfoCache != null) {
+ directShareShortcutInfoCache.put(chooserTarget, shortcutInfo);
+ }
+ }
+ // Sort ChooserTargets by score in descending order
+ Comparator<ChooserTarget> byScore =
+ (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore());
+ Collections.sort(chooserTargetList, byScore);
+ return chooserTargetList;
+ }
+}
diff --git a/java/src/com/android/intentresolver/widget/ActionRow.kt b/java/src/com/android/intentresolver/widget/ActionRow.kt
new file mode 100644
index 0000000..6764d3a
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ActionRow.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.widget
+
+import android.content.res.Resources.ID_NULL
+import android.graphics.drawable.Drawable
+
+interface ActionRow {
+ fun setActions(actions: List<Action>)
+
+ class Action @JvmOverloads constructor(
+ // TODO: apparently, IDs set to this field are used in unit tests only; evaluate whether we
+ // get rid of them
+ val id: Int = ID_NULL,
+ val label: CharSequence?,
+ val icon: Drawable?,
+ val onClicked: Runnable,
+ )
+}
diff --git a/java/src/com/android/intentresolver/widget/ChooserActionRow.kt b/java/src/com/android/intentresolver/widget/ChooserActionRow.kt
new file mode 100644
index 0000000..a4656bb
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ChooserActionRow.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.widget
+
+import android.annotation.LayoutRes
+import android.content.Context
+import android.os.Parcelable
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.Button
+import android.widget.LinearLayout
+import com.android.intentresolver.R
+import com.android.intentresolver.widget.ActionRow.Action
+
+class ChooserActionRow : LinearLayout, ActionRow {
+ 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) {
+ orientation = HORIZONTAL
+ }
+
+ @LayoutRes
+ private val itemLayout = R.layout.chooser_action_button
+ private val itemMargin =
+ context.resources.getDimensionPixelSize(R.dimen.resolver_icon_margin) / 2
+ private var actions: List<Action> = emptyList()
+
+ override fun onRestoreInstanceState(state: Parcelable?) {
+ super.onRestoreInstanceState(state)
+ setActions(actions)
+ }
+
+ override fun setActions(actions: List<Action>) {
+ removeAllViews()
+ this.actions = ArrayList(actions)
+ for (action in actions) {
+ addAction(action)
+ }
+ }
+
+ private fun addAction(action: Action) {
+ val b = LayoutInflater.from(context).inflate(itemLayout, null) as Button
+ if (action.icon != null) {
+ val size = resources
+ .getDimensionPixelSize(R.dimen.chooser_action_button_icon_size)
+ action.icon.setBounds(0, 0, size, size)
+ b.setCompoundDrawablesRelative(action.icon, null, null, null)
+ }
+ b.text = action.label ?: ""
+ b.setOnClickListener {
+ action.onClicked.run()
+ }
+ b.id = action.id
+ addView(b)
+ }
+
+ override fun generateDefaultLayoutParams(): LayoutParams =
+ super.generateDefaultLayoutParams().apply {
+ setMarginsRelative(itemMargin, 0, itemMargin, 0)
+ }
+}
diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
new file mode 100644
index 0000000..a37ef95
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt
@@ -0,0 +1,178 @@
+/*
+ * 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.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?
+
+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)
+ }
+
+ /**
+ * 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.
+ */
+ 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
+ }
+}
diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java
new file mode 100644
index 0000000..f5e2051
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java
@@ -0,0 +1,1280 @@
+/*
+ * Copyright (C) 2014 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 static android.content.res.Resources.ID_NULL;
+
+import android.annotation.IdRes;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.metrics.LogMaker;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.ViewTreeObserver;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
+import android.view.animation.AnimationUtils;
+import android.widget.AbsListView;
+import android.widget.OverScroller;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.intentresolver.R;
+import com.android.internal.logging.MetricsLogger;
+import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
+
+public class ResolverDrawerLayout extends ViewGroup {
+ private static final String TAG = "ResolverDrawerLayout";
+ private MetricsLogger mMetricsLogger;
+
+ /**
+ * Max width of the whole drawer layout
+ */
+ private final int mMaxWidth;
+
+ /**
+ * Max total visible height of views not marked always-show when in the closed/initial state
+ */
+ private int mMaxCollapsedHeight;
+
+ /**
+ * Max total visible height of views not marked always-show when in the closed/initial state
+ * when a default option is present
+ */
+ private int mMaxCollapsedHeightSmall;
+
+ /**
+ * Whether {@code mMaxCollapsedHeightSmall} was set explicitly as a layout attribute or
+ * inferred by {@code mMaxCollapsedHeight}.
+ */
+ private final boolean mIsMaxCollapsedHeightSmallExplicit;
+
+ private boolean mSmallCollapsed;
+
+ /**
+ * Move views down from the top by this much in px
+ */
+ private float mCollapseOffset;
+
+ /**
+ * Track fractions of pixels from drag calculations. Without this, the view offsets get
+ * out of sync due to frequently dropping fractions of a pixel from '(int) dy' casts.
+ */
+ private float mDragRemainder = 0.0f;
+ private int mHeightUsed;
+ private int mCollapsibleHeight;
+ private int mAlwaysShowHeight;
+
+ /**
+ * The height in pixels of reserved space added to the top of the collapsed UI;
+ * e.g. chooser targets
+ */
+ private int mCollapsibleHeightReserved;
+
+ private int mTopOffset;
+ private boolean mShowAtTop;
+ @IdRes
+ private int mIgnoreOffsetTopLimitViewId = ID_NULL;
+
+ private boolean mIsDragging;
+ private boolean mOpenOnClick;
+ private boolean mOpenOnLayout;
+ private boolean mDismissOnScrollerFinished;
+ private final int mTouchSlop;
+ private final float mMinFlingVelocity;
+ private final OverScroller mScroller;
+ private final VelocityTracker mVelocityTracker;
+
+ private Drawable mScrollIndicatorDrawable;
+
+ private OnDismissedListener mOnDismissedListener;
+ private RunOnDismissedListener mRunOnDismissedListener;
+ private OnCollapsedChangedListener mOnCollapsedChangedListener;
+
+ private boolean mDismissLocked;
+
+ private float mInitialTouchX;
+ private float mInitialTouchY;
+ private float mLastTouchY;
+ private int mActivePointerId = MotionEvent.INVALID_POINTER_ID;
+
+ private final Rect mTempRect = new Rect();
+
+ private AbsListView mNestedListChild;
+ private RecyclerView mNestedRecyclerChild;
+
+ private final ViewTreeObserver.OnTouchModeChangeListener mTouchModeChangeListener =
+ new ViewTreeObserver.OnTouchModeChangeListener() {
+ @Override
+ public void onTouchModeChanged(boolean isInTouchMode) {
+ if (!isInTouchMode && hasFocus() && isDescendantClipped(getFocusedChild())) {
+ smoothScrollTo(0, 0);
+ }
+ }
+ };
+
+ public ResolverDrawerLayout(Context context) {
+ this(context, null);
+ }
+
+ public ResolverDrawerLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public ResolverDrawerLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ResolverDrawerLayout,
+ defStyleAttr, 0);
+ mMaxWidth = a.getDimensionPixelSize(R.styleable.ResolverDrawerLayout_android_maxWidth, -1);
+ mMaxCollapsedHeight = a.getDimensionPixelSize(
+ R.styleable.ResolverDrawerLayout_maxCollapsedHeight, 0);
+ mMaxCollapsedHeightSmall = a.getDimensionPixelSize(
+ R.styleable.ResolverDrawerLayout_maxCollapsedHeightSmall,
+ mMaxCollapsedHeight);
+ mIsMaxCollapsedHeightSmallExplicit =
+ a.hasValue(R.styleable.ResolverDrawerLayout_maxCollapsedHeightSmall);
+ mShowAtTop = a.getBoolean(R.styleable.ResolverDrawerLayout_showAtTop, false);
+ if (a.hasValue(R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit)) {
+ mIgnoreOffsetTopLimitViewId = a.getResourceId(
+ R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit, ID_NULL);
+ }
+ a.recycle();
+
+ mScrollIndicatorDrawable = mContext.getDrawable(
+ com.android.internal.R.drawable.scroll_indicator_material);
+
+ mScroller = new OverScroller(context, AnimationUtils.loadInterpolator(context,
+ android.R.interpolator.decelerate_quint));
+ mVelocityTracker = VelocityTracker.obtain();
+
+ final ViewConfiguration vc = ViewConfiguration.get(context);
+ mTouchSlop = vc.getScaledTouchSlop();
+ mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
+
+ setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ }
+
+ /**
+ * Dynamically set the max collapsed height. Note this also updates the small collapsed
+ * height if it wasn't specified explicitly.
+ */
+ public void setMaxCollapsedHeight(int heightInPixels) {
+ if (heightInPixels == mMaxCollapsedHeight) {
+ return;
+ }
+ mMaxCollapsedHeight = heightInPixels;
+ if (!mIsMaxCollapsedHeightSmallExplicit) {
+ mMaxCollapsedHeightSmall = mMaxCollapsedHeight;
+ }
+ requestLayout();
+ }
+
+ public void setSmallCollapsed(boolean smallCollapsed) {
+ if (mSmallCollapsed != smallCollapsed) {
+ mSmallCollapsed = smallCollapsed;
+ requestLayout();
+ }
+ }
+
+ public boolean isSmallCollapsed() {
+ return mSmallCollapsed;
+ }
+
+ public boolean isCollapsed() {
+ return mCollapseOffset > 0;
+ }
+
+ public void setShowAtTop(boolean showOnTop) {
+ if (mShowAtTop != showOnTop) {
+ mShowAtTop = showOnTop;
+ requestLayout();
+ }
+ }
+
+ public boolean getShowAtTop() {
+ return mShowAtTop;
+ }
+
+ public void setCollapsed(boolean collapsed) {
+ if (!isLaidOut()) {
+ mOpenOnLayout = !collapsed;
+ } else {
+ smoothScrollTo(collapsed ? mCollapsibleHeight : 0, 0);
+ }
+ }
+
+ public void setCollapsibleHeightReserved(int heightPixels) {
+ final int oldReserved = mCollapsibleHeightReserved;
+ mCollapsibleHeightReserved = heightPixels;
+ if (oldReserved != mCollapsibleHeightReserved) {
+ requestLayout();
+ }
+
+ final int dReserved = mCollapsibleHeightReserved - oldReserved;
+ if (dReserved != 0 && mIsDragging) {
+ mLastTouchY -= dReserved;
+ }
+
+ final int oldCollapsibleHeight = updateCollapsibleHeight();
+ if (updateCollapseOffset(oldCollapsibleHeight, !isDragging())) {
+ return;
+ }
+
+ invalidate();
+ }
+
+ public void setDismissLocked(boolean locked) {
+ mDismissLocked = locked;
+ }
+
+ private boolean isMoving() {
+ return mIsDragging || !mScroller.isFinished();
+ }
+
+ private boolean isDragging() {
+ return mIsDragging || getNestedScrollAxes() == SCROLL_AXIS_VERTICAL;
+ }
+
+ private boolean updateCollapseOffset(int oldCollapsibleHeight, boolean remainClosed) {
+ if (oldCollapsibleHeight == mCollapsibleHeight) {
+ return false;
+ }
+
+ if (getShowAtTop()) {
+ // Keep the drawer fully open.
+ setCollapseOffset(0);
+ return false;
+ }
+
+ if (isLaidOut()) {
+ final boolean isCollapsedOld = mCollapseOffset != 0;
+ if (remainClosed && (oldCollapsibleHeight < mCollapsibleHeight
+ && mCollapseOffset == oldCollapsibleHeight)) {
+ // Stay closed even at the new height.
+ setCollapseOffset(mCollapsibleHeight);
+ } else {
+ setCollapseOffset(Math.min(mCollapseOffset, mCollapsibleHeight));
+ }
+ final boolean isCollapsedNew = mCollapseOffset != 0;
+ if (isCollapsedOld != isCollapsedNew) {
+ onCollapsedChanged(isCollapsedNew);
+ }
+ } else {
+ // Start out collapsed at first unless we restored state for otherwise
+ setCollapseOffset(mOpenOnLayout ? 0 : mCollapsibleHeight);
+ }
+ return true;
+ }
+
+ private void setCollapseOffset(float collapseOffset) {
+ if (mCollapseOffset != collapseOffset) {
+ mCollapseOffset = collapseOffset;
+ requestLayout();
+ }
+ }
+
+ private int getMaxCollapsedHeight() {
+ return (isSmallCollapsed() ? mMaxCollapsedHeightSmall : mMaxCollapsedHeight)
+ + mCollapsibleHeightReserved;
+ }
+
+ public void setOnDismissedListener(OnDismissedListener listener) {
+ mOnDismissedListener = listener;
+ }
+
+ private boolean isDismissable() {
+ return mOnDismissedListener != null && !mDismissLocked;
+ }
+
+ public void setOnCollapsedChangedListener(OnCollapsedChangedListener listener) {
+ mOnCollapsedChangedListener = listener;
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent ev) {
+ final int action = ev.getActionMasked();
+
+ if (action == MotionEvent.ACTION_DOWN) {
+ mVelocityTracker.clear();
+ }
+
+ mVelocityTracker.addMovement(ev);
+
+ switch (action) {
+ case MotionEvent.ACTION_DOWN: {
+ final float x = ev.getX();
+ final float y = ev.getY();
+ mInitialTouchX = x;
+ mInitialTouchY = mLastTouchY = y;
+ mOpenOnClick = isListChildUnderClipped(x, y) && mCollapseOffset > 0;
+ }
+ break;
+
+ case MotionEvent.ACTION_MOVE: {
+ final float x = ev.getX();
+ final float y = ev.getY();
+ final float dy = y - mInitialTouchY;
+ if (Math.abs(dy) > mTouchSlop && findChildUnder(x, y) != null &&
+ (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
+ mActivePointerId = ev.getPointerId(0);
+ mIsDragging = true;
+ mLastTouchY = Math.max(mLastTouchY - mTouchSlop,
+ Math.min(mLastTouchY + dy, mLastTouchY + mTouchSlop));
+ }
+ }
+ break;
+
+ case MotionEvent.ACTION_POINTER_UP: {
+ onSecondaryPointerUp(ev);
+ }
+ break;
+
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ resetTouch();
+ }
+ break;
+ }
+
+ if (mIsDragging) {
+ abortAnimation();
+ }
+ return mIsDragging || mOpenOnClick;
+ }
+
+ private boolean isNestedListChildScrolled() {
+ return mNestedListChild != null
+ && mNestedListChild.getChildCount() > 0
+ && (mNestedListChild.getFirstVisiblePosition() > 0
+ || mNestedListChild.getChildAt(0).getTop() < 0);
+ }
+
+ private boolean isNestedRecyclerChildScrolled() {
+ if (mNestedRecyclerChild != null && mNestedRecyclerChild.getChildCount() > 0) {
+ final RecyclerView.ViewHolder vh =
+ mNestedRecyclerChild.findViewHolderForAdapterPosition(0);
+ return vh == null || vh.itemView.getTop() < 0;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent ev) {
+ final int action = ev.getActionMasked();
+
+ mVelocityTracker.addMovement(ev);
+
+ boolean handled = false;
+ switch (action) {
+ case MotionEvent.ACTION_DOWN: {
+ final float x = ev.getX();
+ final float y = ev.getY();
+ mInitialTouchX = x;
+ mInitialTouchY = mLastTouchY = y;
+ mActivePointerId = ev.getPointerId(0);
+ final boolean hitView = findChildUnder(mInitialTouchX, mInitialTouchY) != null;
+ handled = isDismissable() || mCollapsibleHeight > 0;
+ mIsDragging = hitView && handled;
+ abortAnimation();
+ }
+ break;
+
+ case MotionEvent.ACTION_MOVE: {
+ int index = ev.findPointerIndex(mActivePointerId);
+ if (index < 0) {
+ Log.e(TAG, "Bad pointer id " + mActivePointerId + ", resetting");
+ index = 0;
+ mActivePointerId = ev.getPointerId(0);
+ mInitialTouchX = ev.getX();
+ mInitialTouchY = mLastTouchY = ev.getY();
+ }
+ final float x = ev.getX(index);
+ final float y = ev.getY(index);
+ if (!mIsDragging) {
+ final float dy = y - mInitialTouchY;
+ if (Math.abs(dy) > mTouchSlop && findChildUnder(x, y) != null) {
+ handled = mIsDragging = true;
+ mLastTouchY = Math.max(mLastTouchY - mTouchSlop,
+ Math.min(mLastTouchY + dy, mLastTouchY + mTouchSlop));
+ }
+ }
+ if (mIsDragging) {
+ final float dy = y - mLastTouchY;
+ if (dy > 0 && isNestedListChildScrolled()) {
+ mNestedListChild.smoothScrollBy((int) -dy, 0);
+ } else if (dy > 0 && isNestedRecyclerChildScrolled()) {
+ mNestedRecyclerChild.scrollBy(0, (int) -dy);
+ } else {
+ performDrag(dy);
+ }
+ }
+ mLastTouchY = y;
+ }
+ break;
+
+ case MotionEvent.ACTION_POINTER_DOWN: {
+ final int pointerIndex = ev.getActionIndex();
+ mActivePointerId = ev.getPointerId(pointerIndex);
+ mInitialTouchX = ev.getX(pointerIndex);
+ mInitialTouchY = mLastTouchY = ev.getY(pointerIndex);
+ }
+ break;
+
+ case MotionEvent.ACTION_POINTER_UP: {
+ onSecondaryPointerUp(ev);
+ }
+ break;
+
+ case MotionEvent.ACTION_UP: {
+ final boolean wasDragging = mIsDragging;
+ mIsDragging = false;
+ if (!wasDragging && findChildUnder(mInitialTouchX, mInitialTouchY) == null &&
+ findChildUnder(ev.getX(), ev.getY()) == null) {
+ if (isDismissable()) {
+ dispatchOnDismissed();
+ resetTouch();
+ return true;
+ }
+ }
+ if (mOpenOnClick && Math.abs(ev.getX() - mInitialTouchX) < mTouchSlop &&
+ Math.abs(ev.getY() - mInitialTouchY) < mTouchSlop) {
+ smoothScrollTo(0, 0);
+ return true;
+ }
+ mVelocityTracker.computeCurrentVelocity(1000);
+ final float yvel = mVelocityTracker.getYVelocity(mActivePointerId);
+ if (Math.abs(yvel) > mMinFlingVelocity) {
+ if (getShowAtTop()) {
+ if (isDismissable() && yvel < 0) {
+ abortAnimation();
+ dismiss();
+ } else {
+ smoothScrollTo(yvel < 0 ? 0 : mCollapsibleHeight, yvel);
+ }
+ } else {
+ if (isDismissable()
+ && yvel > 0 && mCollapseOffset > mCollapsibleHeight) {
+ smoothScrollTo(mHeightUsed, yvel);
+ mDismissOnScrollerFinished = true;
+ } else {
+ scrollNestedScrollableChildBackToTop();
+ smoothScrollTo(yvel < 0 ? 0 : mCollapsibleHeight, yvel);
+ }
+ }
+ }else {
+ smoothScrollTo(
+ mCollapseOffset < mCollapsibleHeight / 2 ? 0 : mCollapsibleHeight, 0);
+ }
+ resetTouch();
+ }
+ break;
+
+ case MotionEvent.ACTION_CANCEL: {
+ if (mIsDragging) {
+ smoothScrollTo(
+ mCollapseOffset < mCollapsibleHeight / 2 ? 0 : mCollapsibleHeight, 0);
+ }
+ resetTouch();
+ return true;
+ }
+ }
+
+ return handled;
+ }
+
+ /**
+ * Scroll nested scrollable child back to top if it has been scrolled.
+ */
+ public void scrollNestedScrollableChildBackToTop() {
+ if (isNestedListChildScrolled()) {
+ mNestedListChild.smoothScrollToPosition(0);
+ } else if (isNestedRecyclerChildScrolled()) {
+ mNestedRecyclerChild.smoothScrollToPosition(0);
+ }
+ }
+
+ private void onSecondaryPointerUp(MotionEvent ev) {
+ final int pointerIndex = ev.getActionIndex();
+ final int pointerId = ev.getPointerId(pointerIndex);
+ if (pointerId == mActivePointerId) {
+ // This was our active pointer going up. Choose a new
+ // active pointer and adjust accordingly.
+ final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
+ mInitialTouchX = ev.getX(newPointerIndex);
+ mInitialTouchY = mLastTouchY = ev.getY(newPointerIndex);
+ mActivePointerId = ev.getPointerId(newPointerIndex);
+ }
+ }
+
+ private void resetTouch() {
+ mActivePointerId = MotionEvent.INVALID_POINTER_ID;
+ mIsDragging = false;
+ mOpenOnClick = false;
+ mInitialTouchX = mInitialTouchY = mLastTouchY = 0;
+ mVelocityTracker.clear();
+ }
+
+ private void dismiss() {
+ mRunOnDismissedListener = new RunOnDismissedListener();
+ post(mRunOnDismissedListener);
+ }
+
+ @Override
+ public void computeScroll() {
+ super.computeScroll();
+ if (mScroller.computeScrollOffset()) {
+ final boolean keepGoing = !mScroller.isFinished();
+ performDrag(mScroller.getCurrY() - mCollapseOffset);
+ if (keepGoing) {
+ postInvalidateOnAnimation();
+ } else if (mDismissOnScrollerFinished && mOnDismissedListener != null) {
+ dismiss();
+ }
+ }
+ }
+
+ private void abortAnimation() {
+ mScroller.abortAnimation();
+ mRunOnDismissedListener = null;
+ mDismissOnScrollerFinished = false;
+ }
+
+ private float performDrag(float dy) {
+ if (getShowAtTop()) {
+ return 0;
+ }
+
+ final float newPos = Math.max(0, Math.min(mCollapseOffset + dy, mHeightUsed));
+ if (newPos != mCollapseOffset) {
+ dy = newPos - mCollapseOffset;
+
+ mDragRemainder += dy - (int) dy;
+ if (mDragRemainder >= 1.0f) {
+ mDragRemainder -= 1.0f;
+ dy += 1.0f;
+ } else if (mDragRemainder <= -1.0f) {
+ mDragRemainder += 1.0f;
+ dy -= 1.0f;
+ }
+
+ boolean isIgnoreOffsetLimitSet = false;
+ int ignoreOffsetLimit = 0;
+ View ignoreOffsetLimitView = findIgnoreOffsetLimitView();
+ if (ignoreOffsetLimitView != null) {
+ LayoutParams lp = (LayoutParams) ignoreOffsetLimitView.getLayoutParams();
+ ignoreOffsetLimit = ignoreOffsetLimitView.getBottom() + lp.bottomMargin;
+ isIgnoreOffsetLimitSet = true;
+ }
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ if (child.getVisibility() == View.GONE) {
+ continue;
+ }
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (!lp.ignoreOffset) {
+ child.offsetTopAndBottom((int) dy);
+ } else if (isIgnoreOffsetLimitSet) {
+ int top = child.getTop();
+ int targetTop = Math.max(
+ (int) (ignoreOffsetLimit + lp.topMargin + dy),
+ lp.mFixedTop);
+ if (top != targetTop) {
+ child.offsetTopAndBottom(targetTop - top);
+ }
+ ignoreOffsetLimit = child.getBottom() + lp.bottomMargin;
+ }
+ }
+ final boolean isCollapsedOld = mCollapseOffset != 0;
+ mCollapseOffset = newPos;
+ mTopOffset += dy;
+ final boolean isCollapsedNew = newPos != 0;
+ if (isCollapsedOld != isCollapsedNew) {
+ onCollapsedChanged(isCollapsedNew);
+ getMetricsLogger().write(
+ new LogMaker(MetricsEvent.ACTION_SHARESHEET_COLLAPSED_CHANGED)
+ .setSubtype(isCollapsedNew ? 1 : 0));
+ }
+ onScrollChanged(0, (int) newPos, 0, (int) (newPos - dy));
+ postInvalidateOnAnimation();
+ return dy;
+ }
+ return 0;
+ }
+
+ private void onCollapsedChanged(boolean isCollapsed) {
+ notifyViewAccessibilityStateChangedIfNeeded(
+ AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED);
+
+ if (mScrollIndicatorDrawable != null) {
+ setWillNotDraw(!isCollapsed);
+ }
+
+ if (mOnCollapsedChangedListener != null) {
+ mOnCollapsedChangedListener.onCollapsedChanged(isCollapsed);
+ }
+ }
+
+ void dispatchOnDismissed() {
+ if (mOnDismissedListener != null) {
+ mOnDismissedListener.onDismissed();
+ }
+ if (mRunOnDismissedListener != null) {
+ removeCallbacks(mRunOnDismissedListener);
+ mRunOnDismissedListener = null;
+ }
+ }
+
+ private void smoothScrollTo(int yOffset, float velocity) {
+ abortAnimation();
+ final int sy = (int) mCollapseOffset;
+ int dy = yOffset - sy;
+ if (dy == 0) {
+ return;
+ }
+
+ final int height = getHeight();
+ final int halfHeight = height / 2;
+ final float distanceRatio = Math.min(1f, 1.0f * Math.abs(dy) / height);
+ final float distance = halfHeight + halfHeight *
+ distanceInfluenceForSnapDuration(distanceRatio);
+
+ int duration = 0;
+ velocity = Math.abs(velocity);
+ if (velocity > 0) {
+ duration = 4 * Math.round(1000 * Math.abs(distance / velocity));
+ } else {
+ final float pageDelta = (float) Math.abs(dy) / height;
+ duration = (int) ((pageDelta + 1) * 100);
+ }
+ duration = Math.min(duration, 300);
+
+ mScroller.startScroll(0, sy, 0, dy, duration);
+ postInvalidateOnAnimation();
+ }
+
+ private float distanceInfluenceForSnapDuration(float f) {
+ f -= 0.5f; // center the values about 0.
+ f *= 0.3f * Math.PI / 2.0f;
+ return (float) Math.sin(f);
+ }
+
+ /**
+ * Note: this method doesn't take Z into account for overlapping views
+ * since it is only used in contexts where this doesn't affect the outcome.
+ */
+ private View findChildUnder(float x, float y) {
+ return findChildUnder(this, x, y);
+ }
+
+ private static View findChildUnder(ViewGroup parent, float x, float y) {
+ final int childCount = parent.getChildCount();
+ for (int i = childCount - 1; i >= 0; i--) {
+ final View child = parent.getChildAt(i);
+ if (isChildUnder(child, x, y)) {
+ return child;
+ }
+ }
+ return null;
+ }
+
+ private View findListChildUnder(float x, float y) {
+ View v = findChildUnder(x, y);
+ while (v != null) {
+ x -= v.getX();
+ y -= v.getY();
+ if (v instanceof AbsListView) {
+ // One more after this.
+ return findChildUnder((ViewGroup) v, x, y);
+ }
+ v = v instanceof ViewGroup ? findChildUnder((ViewGroup) v, x, y) : null;
+ }
+ return v;
+ }
+
+ /**
+ * This only checks clipping along the bottom edge.
+ */
+ private boolean isListChildUnderClipped(float x, float y) {
+ final View listChild = findListChildUnder(x, y);
+ return listChild != null && isDescendantClipped(listChild);
+ }
+
+ private boolean isDescendantClipped(View child) {
+ mTempRect.set(0, 0, child.getWidth(), child.getHeight());
+ offsetDescendantRectToMyCoords(child, mTempRect);
+ View directChild;
+ if (child.getParent() == this) {
+ directChild = child;
+ } else {
+ View v = child;
+ ViewParent p = child.getParent();
+ while (p != this) {
+ v = (View) p;
+ p = v.getParent();
+ }
+ directChild = v;
+ }
+
+ // ResolverDrawerLayout lays out vertically in child order;
+ // the next view and forward is what to check against.
+ int clipEdge = getHeight() - getPaddingBottom();
+ final int childCount = getChildCount();
+ for (int i = indexOfChild(directChild) + 1; i < childCount; i++) {
+ final View nextChild = getChildAt(i);
+ if (nextChild.getVisibility() == GONE) {
+ continue;
+ }
+ clipEdge = Math.min(clipEdge, nextChild.getTop());
+ }
+ return mTempRect.bottom > clipEdge;
+ }
+
+ private static boolean isChildUnder(View child, float x, float y) {
+ final float left = child.getX();
+ final float top = child.getY();
+ final float right = left + child.getWidth();
+ final float bottom = top + child.getHeight();
+ return x >= left && y >= top && x < right && y < bottom;
+ }
+
+ @Override
+ public void requestChildFocus(View child, View focused) {
+ super.requestChildFocus(child, focused);
+ if (!isInTouchMode() && isDescendantClipped(focused)) {
+ smoothScrollTo(0, 0);
+ }
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ getViewTreeObserver().addOnTouchModeChangeListener(mTouchModeChangeListener);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ getViewTreeObserver().removeOnTouchModeChangeListener(mTouchModeChangeListener);
+ abortAnimation();
+ }
+
+ @Override
+ public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
+ if ((nestedScrollAxes & View.SCROLL_AXIS_VERTICAL) != 0) {
+ if (target instanceof AbsListView) {
+ mNestedListChild = (AbsListView) target;
+ }
+ if (target instanceof RecyclerView) {
+ mNestedRecyclerChild = (RecyclerView) target;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void onNestedScrollAccepted(View child, View target, int axes) {
+ super.onNestedScrollAccepted(child, target, axes);
+ }
+
+ @Override
+ public void onStopNestedScroll(View child) {
+ super.onStopNestedScroll(child);
+ if (mScroller.isFinished()) {
+ smoothScrollTo(mCollapseOffset < mCollapsibleHeight / 2 ? 0 : mCollapsibleHeight, 0);
+ }
+ }
+
+ @Override
+ public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
+ int dxUnconsumed, int dyUnconsumed) {
+ if (dyUnconsumed < 0) {
+ performDrag(-dyUnconsumed);
+ }
+ }
+
+ @Override
+ public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
+ if (dy > 0) {
+ consumed[1] = (int) -performDrag(-dy);
+ }
+ }
+
+ @Override
+ public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
+ if (!getShowAtTop() && velocityY > mMinFlingVelocity && mCollapseOffset != 0) {
+ smoothScrollTo(0, velocityY);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
+ if (!consumed && Math.abs(velocityY) > mMinFlingVelocity) {
+ if (getShowAtTop()) {
+ if (isDismissable() && velocityY > 0) {
+ abortAnimation();
+ dismiss();
+ } else {
+ smoothScrollTo(velocityY < 0 ? mCollapsibleHeight : 0, velocityY);
+ }
+ } else {
+ if (isDismissable()
+ && velocityY < 0 && mCollapseOffset > mCollapsibleHeight) {
+ smoothScrollTo(mHeightUsed, velocityY);
+ mDismissOnScrollerFinished = true;
+ } else {
+ smoothScrollTo(velocityY > 0 ? 0 : mCollapsibleHeight, velocityY);
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private boolean performAccessibilityActionCommon(int action) {
+ switch (action) {
+ case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
+ case AccessibilityNodeInfo.ACTION_EXPAND:
+ case com.android.internal.R.id.accessibilityActionScrollDown:
+ if (mCollapseOffset != 0) {
+ smoothScrollTo(0, 0);
+ return true;
+ }
+ break;
+ case AccessibilityNodeInfo.ACTION_COLLAPSE:
+ if (mCollapseOffset < mCollapsibleHeight) {
+ smoothScrollTo(mCollapsibleHeight, 0);
+ return true;
+ }
+ break;
+ case AccessibilityNodeInfo.ACTION_DISMISS:
+ if ((mCollapseOffset < mHeightUsed) && isDismissable()) {
+ smoothScrollTo(mHeightUsed, 0);
+ mDismissOnScrollerFinished = true;
+ return true;
+ }
+ break;
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean onNestedPrePerformAccessibilityAction(View target, int action, Bundle args) {
+ if (super.onNestedPrePerformAccessibilityAction(target, action, args)) {
+ return true;
+ }
+
+ return performAccessibilityActionCommon(action);
+ }
+
+ @Override
+ public CharSequence getAccessibilityClassName() {
+ // Since we support scrolling, make this ViewGroup look like a
+ // ScrollView. This is kind of a hack until we have support for
+ // specifying auto-scroll behavior.
+ return android.widget.ScrollView.class.getName();
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfoInternal(info);
+
+ if (isEnabled()) {
+ if (mCollapseOffset != 0) {
+ info.addAction(AccessibilityAction.ACTION_SCROLL_FORWARD);
+ info.addAction(AccessibilityAction.ACTION_EXPAND);
+ info.addAction(AccessibilityAction.ACTION_SCROLL_DOWN);
+ info.setScrollable(true);
+ }
+ if ((mCollapseOffset < mHeightUsed)
+ && ((mCollapseOffset < mCollapsibleHeight) || isDismissable())) {
+ info.addAction(AccessibilityAction.ACTION_SCROLL_UP);
+ info.setScrollable(true);
+ }
+ if (mCollapseOffset < mCollapsibleHeight) {
+ info.addAction(AccessibilityAction.ACTION_COLLAPSE);
+ }
+ if (mCollapseOffset < mHeightUsed && isDismissable()) {
+ info.addAction(AccessibilityAction.ACTION_DISMISS);
+ }
+ }
+
+ // This view should never get accessibility focus, but it's interactive
+ // via nested scrolling, so we can't hide it completely.
+ info.removeAction(AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS);
+ }
+
+ @Override
+ public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
+ if (action == AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS.getId()) {
+ // This view should never get accessibility focus.
+ return false;
+ }
+
+ if (super.performAccessibilityActionInternal(action, arguments)) {
+ return true;
+ }
+
+ return performAccessibilityActionCommon(action);
+ }
+
+ @Override
+ public void onDrawForeground(Canvas canvas) {
+ if (mScrollIndicatorDrawable != null) {
+ mScrollIndicatorDrawable.draw(canvas);
+ }
+
+ super.onDrawForeground(canvas);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final int sourceWidth = MeasureSpec.getSize(widthMeasureSpec);
+ int widthSize = sourceWidth;
+ final int heightSize = MeasureSpec.getSize(heightMeasureSpec);
+
+ // Single-use layout; just ignore the mode and use available space.
+ // Clamp to maxWidth.
+ if (mMaxWidth >= 0) {
+ widthSize = Math.min(widthSize, mMaxWidth + getPaddingLeft() + getPaddingRight());
+ }
+
+ final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY);
+ final int heightSpec = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY);
+
+ // Currently we allot more height than is really needed so that the entirety of the
+ // sheet may be pulled up.
+ // TODO: Restrict the height here to be the right value.
+ int heightUsed = 0;
+
+ // Measure always-show children first.
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (lp.alwaysShow && child.getVisibility() != GONE) {
+ if (lp.maxHeight != -1) {
+ final int remainingHeight = heightSize - heightUsed;
+ measureChildWithMargins(child, widthSpec, 0,
+ MeasureSpec.makeMeasureSpec(lp.maxHeight, MeasureSpec.AT_MOST),
+ lp.maxHeight > remainingHeight ? lp.maxHeight - remainingHeight : 0);
+ } else {
+ measureChildWithMargins(child, widthSpec, 0, heightSpec, heightUsed);
+ }
+ heightUsed += child.getMeasuredHeight();
+ }
+ }
+
+ mAlwaysShowHeight = heightUsed;
+
+ // And now the rest.
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (!lp.alwaysShow && child.getVisibility() != GONE) {
+ if (lp.maxHeight != -1) {
+ final int remainingHeight = heightSize - heightUsed;
+ measureChildWithMargins(child, widthSpec, 0,
+ MeasureSpec.makeMeasureSpec(lp.maxHeight, MeasureSpec.AT_MOST),
+ lp.maxHeight > remainingHeight ? lp.maxHeight - remainingHeight : 0);
+ } else {
+ measureChildWithMargins(child, widthSpec, 0, heightSpec, heightUsed);
+ }
+ heightUsed += child.getMeasuredHeight();
+ }
+ }
+
+ mHeightUsed = heightUsed;
+ int oldCollapsibleHeight = updateCollapsibleHeight();
+ updateCollapseOffset(oldCollapsibleHeight, !isDragging());
+
+ if (getShowAtTop()) {
+ mTopOffset = 0;
+ } else {
+ mTopOffset = Math.max(0, heightSize - mHeightUsed) + (int) mCollapseOffset;
+ }
+
+ setMeasuredDimension(sourceWidth, heightSize);
+ }
+
+ private int updateCollapsibleHeight() {
+ final int oldCollapsibleHeight = mCollapsibleHeight;
+ mCollapsibleHeight = Math.max(0, mHeightUsed - mAlwaysShowHeight - getMaxCollapsedHeight());
+ return oldCollapsibleHeight;
+ }
+
+ /**
+ * @return The space reserved by views with 'alwaysShow=true'
+ */
+ public int getAlwaysShowHeight() {
+ return mAlwaysShowHeight;
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ final int width = getWidth();
+
+ View indicatorHost = null;
+
+ int ypos = mTopOffset;
+ final int leftEdge = getPaddingLeft();
+ final int rightEdge = width - getPaddingRight();
+ final int widthAvailable = rightEdge - leftEdge;
+
+ boolean isIgnoreOffsetLimitSet = false;
+ int ignoreOffsetLimit = 0;
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (lp.hasNestedScrollIndicator) {
+ indicatorHost = child;
+ }
+
+ if (child.getVisibility() == GONE) {
+ continue;
+ }
+
+ if (mIgnoreOffsetTopLimitViewId != ID_NULL && !isIgnoreOffsetLimitSet) {
+ if (mIgnoreOffsetTopLimitViewId == child.getId()) {
+ ignoreOffsetLimit = child.getBottom() + lp.bottomMargin;
+ isIgnoreOffsetLimitSet = true;
+ }
+ }
+
+ int top = ypos + lp.topMargin;
+ if (lp.ignoreOffset) {
+ if (!isDragging()) {
+ lp.mFixedTop = (int) (top - mCollapseOffset);
+ }
+ if (isIgnoreOffsetLimitSet) {
+ top = Math.max(ignoreOffsetLimit + lp.topMargin, (int) (top - mCollapseOffset));
+ ignoreOffsetLimit = top + child.getMeasuredHeight() + lp.bottomMargin;
+ } else {
+ top -= mCollapseOffset;
+ }
+ }
+ final int bottom = top + child.getMeasuredHeight();
+
+ final int childWidth = child.getMeasuredWidth();
+ final int left = leftEdge + (widthAvailable - childWidth) / 2;
+ final int right = left + childWidth;
+
+ child.layout(left, top, right, bottom);
+
+ ypos = bottom + lp.bottomMargin;
+ }
+
+ if (mScrollIndicatorDrawable != null) {
+ if (indicatorHost != null) {
+ final int left = indicatorHost.getLeft();
+ final int right = indicatorHost.getRight();
+ final int bottom = indicatorHost.getTop();
+ final int top = bottom - mScrollIndicatorDrawable.getIntrinsicHeight();
+ mScrollIndicatorDrawable.setBounds(left, top, right, bottom);
+ setWillNotDraw(!isCollapsed());
+ } else {
+ mScrollIndicatorDrawable = null;
+ setWillNotDraw(true);
+ }
+ }
+ }
+
+ @Override
+ public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
+ return new LayoutParams(getContext(), attrs);
+ }
+
+ @Override
+ protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
+ if (p instanceof LayoutParams) {
+ return new LayoutParams((LayoutParams) p);
+ } else if (p instanceof MarginLayoutParams) {
+ return new LayoutParams((MarginLayoutParams) p);
+ }
+ return new LayoutParams(p);
+ }
+
+ @Override
+ protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
+ return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
+ }
+
+ @Override
+ protected Parcelable onSaveInstanceState() {
+ final SavedState ss = new SavedState(super.onSaveInstanceState());
+ ss.open = mCollapsibleHeight > 0 && mCollapseOffset == 0;
+ ss.mCollapsibleHeightReserved = mCollapsibleHeightReserved;
+ return ss;
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Parcelable state) {
+ final SavedState ss = (SavedState) state;
+ super.onRestoreInstanceState(ss.getSuperState());
+ mOpenOnLayout = ss.open;
+ mCollapsibleHeightReserved = ss.mCollapsibleHeightReserved;
+ }
+
+ private View findIgnoreOffsetLimitView() {
+ if (mIgnoreOffsetTopLimitViewId == ID_NULL) {
+ return null;
+ }
+ View v = findViewById(mIgnoreOffsetTopLimitViewId);
+ if (v != null && v != this && v.getParent() == this && v.getVisibility() != View.GONE) {
+ return v;
+ }
+ return null;
+ }
+
+ public static class LayoutParams extends MarginLayoutParams {
+ public boolean alwaysShow;
+ public boolean ignoreOffset;
+ public boolean hasNestedScrollIndicator;
+ public int maxHeight;
+ int mFixedTop;
+
+ public LayoutParams(Context c, AttributeSet attrs) {
+ super(c, attrs);
+
+ final TypedArray a = c.obtainStyledAttributes(attrs,
+ R.styleable.ResolverDrawerLayout_LayoutParams);
+ alwaysShow = a.getBoolean(
+ R.styleable.ResolverDrawerLayout_LayoutParams_layout_alwaysShow,
+ false);
+ ignoreOffset = a.getBoolean(
+ R.styleable.ResolverDrawerLayout_LayoutParams_layout_ignoreOffset,
+ false);
+ hasNestedScrollIndicator = a.getBoolean(
+ R.styleable.ResolverDrawerLayout_LayoutParams_layout_hasNestedScrollIndicator,
+ false);
+ maxHeight = a.getDimensionPixelSize(
+ R.styleable.ResolverDrawerLayout_LayoutParams_layout_maxHeight, -1);
+ a.recycle();
+ }
+
+ public LayoutParams(int width, int height) {
+ super(width, height);
+ }
+
+ public LayoutParams(LayoutParams source) {
+ super(source);
+ this.alwaysShow = source.alwaysShow;
+ this.ignoreOffset = source.ignoreOffset;
+ this.hasNestedScrollIndicator = source.hasNestedScrollIndicator;
+ this.maxHeight = source.maxHeight;
+ }
+
+ public LayoutParams(MarginLayoutParams source) {
+ super(source);
+ }
+
+ public LayoutParams(ViewGroup.LayoutParams source) {
+ super(source);
+ }
+ }
+
+ static class SavedState extends BaseSavedState {
+ boolean open;
+ private int mCollapsibleHeightReserved;
+
+ SavedState(Parcelable superState) {
+ super(superState);
+ }
+
+ private SavedState(Parcel in) {
+ super(in);
+ open = in.readInt() != 0;
+ mCollapsibleHeightReserved = in.readInt();
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int flags) {
+ super.writeToParcel(out, flags);
+ out.writeInt(open ? 1 : 0);
+ out.writeInt(mCollapsibleHeightReserved);
+ }
+
+ public static final Parcelable.Creator<SavedState> CREATOR =
+ new Parcelable.Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+
+ /**
+ * Listener for sheet dismissed events.
+ */
+ public interface OnDismissedListener {
+ /**
+ * Callback when the sheet is dismissed by the user.
+ */
+ void onDismissed();
+ }
+
+ /**
+ * Listener for sheet collapsed / expanded events.
+ */
+ public interface OnCollapsedChangedListener {
+ /**
+ * Callback when the sheet is either fully expanded or collapsed.
+ * @param isCollapsed true when collapsed, false when expanded.
+ */
+ void onCollapsedChanged(boolean isCollapsed);
+ }
+
+ private class RunOnDismissedListener implements Runnable {
+ @Override
+ public void run() {
+ dispatchOnDismissed();
+ }
+ }
+
+ private MetricsLogger getMetricsLogger() {
+ if (mMetricsLogger == null) {
+ mMetricsLogger = new MetricsLogger();
+ }
+ return mMetricsLogger;
+ }
+}
diff --git a/java/src/com/android/intentresolver/widget/RoundedRectImageView.java b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java
new file mode 100644
index 0000000..8538041
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/RoundedRectImageView.java
@@ -0,0 +1,132 @@
+/*
+ * 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.widget;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+
+import com.android.intentresolver.R;
+
+/**
+ * {@link ImageView} that rounds the corners around the presented image while obeying view padding.
+ */
+public class RoundedRectImageView extends ImageView {
+ private int mRadius = 0;
+ private Path mPath = new Path();
+ private Paint mOverlayPaint = new Paint(0);
+ private Paint mRoundRectPaint = new Paint(0);
+ private Paint mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private String mExtraImageCount = null;
+
+ public RoundedRectImageView(Context context) {
+ super(context);
+ }
+
+ public RoundedRectImageView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public RoundedRectImageView(Context context, AttributeSet attrs, int defStyleAttr) {
+ this(context, attrs, defStyleAttr, 0);
+ }
+
+ public RoundedRectImageView(
+ Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius);
+
+ mOverlayPaint.setColor(0x99000000);
+ mOverlayPaint.setStyle(Paint.Style.FILL);
+
+ mRoundRectPaint.setColor(context.getResources().getColor(R.color.chooser_row_divider));
+ mRoundRectPaint.setStyle(Paint.Style.STROKE);
+ mRoundRectPaint.setStrokeWidth(context.getResources()
+ .getDimensionPixelSize(R.dimen.chooser_preview_image_border));
+
+ mTextPaint.setColor(Color.WHITE);
+ mTextPaint.setTextSize(context.getResources()
+ .getDimensionPixelSize(R.dimen.chooser_preview_image_font_size));
+ mTextPaint.setTextAlign(Paint.Align.CENTER);
+ }
+
+ private void updatePath(int width, int height) {
+ mPath.reset();
+
+ int imageWidth = width - getPaddingRight() - getPaddingLeft();
+ int imageHeight = height - getPaddingBottom() - getPaddingTop();
+ mPath.addRoundRect(getPaddingLeft(), getPaddingTop(), imageWidth, imageHeight, mRadius,
+ mRadius, Path.Direction.CW);
+ }
+
+ /**
+ * Sets the corner radius on all corners
+ *
+ * param radius 0 for no radius, > 0 for a visible corner radius
+ */
+ public void setRadius(int radius) {
+ mRadius = radius;
+ updatePath(getWidth(), getHeight());
+ }
+
+ /**
+ * Display an overlay with extra image count on 3rd image
+ */
+ public void setExtraImageCount(int count) {
+ if (count > 0) {
+ this.mExtraImageCount = "+" + count;
+ } else {
+ this.mExtraImageCount = null;
+ }
+ invalidate();
+ }
+
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+ updatePath(width, height);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ if (mRadius != 0) {
+ canvas.clipPath(mPath);
+ }
+
+ super.onDraw(canvas);
+
+ int x = getPaddingLeft();
+ int y = getPaddingRight();
+ int width = getWidth() - getPaddingRight() - getPaddingLeft();
+ int height = getHeight() - getPaddingBottom() - getPaddingTop();
+ if (mExtraImageCount != null) {
+ canvas.drawRect(x, y, width, height, mOverlayPaint);
+
+ int xPos = canvas.getWidth() / 2;
+ int yPos = (int) ((canvas.getHeight() / 2.0f)
+ - ((mTextPaint.descent() + mTextPaint.ascent()) / 2.0f));
+
+ canvas.drawText(mExtraImageCount, xPos, yPos, mTextPaint);
+ }
+
+ canvas.drawRoundRect(x, y, width, height, mRadius, mRadius, mRoundRectPaint);
+ }
+}
diff --git a/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
new file mode 100644
index 0000000..a941b97
--- /dev/null
+++ b/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.content.Context
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.intentresolver.R
+
+class ScrollableActionRow : RecyclerView, ActionRow {
+ 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)
+ }
+
+ private val actionsAdapter get() = adapter as Adapter
+
+ override fun setActions(actions: List<ActionRow.Action>) {
+ actionsAdapter.setActions(actions)
+ }
+
+ 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
+ )
+ }
+
+ 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)
+ private val itemLayout = R.layout.chooser_action_view
+ private var actions: List<ActionRow.Action> = emptyList()
+
+ override fun onCreateViewHolder(parent: ViewGroup, type: Int): ViewHolder =
+ ViewHolder(
+ LayoutInflater.from(context).inflate(itemLayout, null) as TextView,
+ iconSize
+ )
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bind(actions[position])
+ }
+
+ override fun getItemCount() = actions.size
+
+ override fun onViewRecycled(holder: ViewHolder) {
+ holder.unbind()
+ }
+
+ override fun onFailedToRecycleView(holder: ViewHolder): Boolean {
+ holder.unbind()
+ return super.onFailedToRecycleView(holder)
+ }
+
+ fun setActions(actions: List<ActionRow.Action>) {
+ this.actions = ArrayList(actions)
+ notifyDataSetChanged()
+ }
+ }
+
+ private class ViewHolder(
+ private val view: TextView, private val iconSize: Int
+ ) : RecyclerView.ViewHolder(view) {
+
+ fun bind(action: ActionRow.Action) {
+ if (action.icon != null) {
+ action.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)
+ }
+ view.text = action.label ?: ""
+ view.setOnClickListener {
+ action.onClicked.run()
+ }
+ view.id = action.id
+ }
+
+ fun unbind() {
+ view.setOnClickListener(null)
+ }
+
+ private fun tintIcon(drawable: Drawable, view: TextView) {
+ val tintList = view.compoundDrawableTintList ?: return
+ drawable.setTintList(tintList)
+ view.compoundDrawableTintMode?.let { drawable.setTintMode(it) }
+ view.compoundDrawableTintBlendMode?.let { drawable.setTintBlendMode(it) }
+ }
+ }
+}
diff --git a/java/tests/Android.bp b/java/tests/Android.bp
index fdabc4e..2913d12 100644
--- a/java/tests/Android.bp
+++ b/java/tests/Android.bp
@@ -7,7 +7,7 @@
name: "IntentResolverUnitTests",
// Include all test java files.
- srcs: ["src/**/*.java"],
+ srcs: ["src/**/*.java", "src/**/*.kt"],
libs: [
"android.test.runner",
@@ -19,8 +19,8 @@
static_libs: [
"IntentResolver-core",
- "ChooserActivityTestsLib",
"androidx.test.rules",
+ "androidx.test.ext.junit",
"mockito-target-minus-junit4",
"androidx.test.espresso.core",
"truth-prebuilt",
diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml
index bfe3a39..306eccb 100644
--- a/java/tests/AndroidManifest.xml
+++ b/java/tests/AndroidManifest.xml
@@ -23,10 +23,12 @@
<uses-permission android:name="android.permission.QUERY_USERS"/>
<uses-permission android:name="android.permission.READ_CLIPBOARD_IN_BACKGROUND"/>
<uses-permission android:name="android.permission.WRITE_DEVICE_CONFIG"/>
+ <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" />
- <application>
+ <application android:name="com.android.intentresolver.TestApplication">
<uses-library android:name="android.test.runner" />
<activity android:name="com.android.intentresolver.ChooserWrapperActivity" />
+ <activity android:name="com.android.intentresolver.ResolverWrapperActivity" />
</application>
<instrumentation android:name="android.testing.TestableInstrumentation"
diff --git a/java/tests/AndroidTest.xml b/java/tests/AndroidTest.xml
index f4e75c4..d1d77c1 100644
--- a/java/tests/AndroidTest.xml
+++ b/java/tests/AndroidTest.xml
@@ -14,7 +14,7 @@
limitations under the License.
-->
<configuration description="Run IntentResolver Tests.">
- <!--<target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
+ <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup">
<option name="test-file-name" value="IntentResolverUnitTests.apk" />
</target_preparer>
@@ -24,5 +24,5 @@
<option name="package" value="com.android.intentresolver.tests" />
<option name="runner" value="android.testing.TestableInstrumentation" />
<option name="hidden-api-checks" value="false"/>
- </test>-->
+ </test>
</configuration>
diff --git a/java/tests/res/drawable/test320x240.png b/java/tests/res/drawable/test320x240.png
new file mode 100644
index 0000000..9b5800d
--- /dev/null
+++ b/java/tests/res/drawable/test320x240.png
Binary files differ
diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java
deleted file mode 100644
index e4146cc..0000000
--- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java
+++ /dev/null
@@ -1,134 +0,0 @@
-/*
- * Copyright (C) 2020 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.internal.logging.InstanceId;
-import com.android.internal.logging.UiEventLogger;
-import com.android.internal.util.FrameworkStatsLog;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class ChooserActivityLoggerFake implements ChooserActivityLogger {
- static class CallRecord {
- // shared fields between all logs
- public int atomId;
- public String packageName;
- public InstanceId instanceId;
-
- // generic log field
- public UiEventLogger.UiEventEnum event;
-
- // share started fields
- public String mimeType;
- public int appProvidedDirect;
- public int appProvidedApp;
- public boolean isWorkprofile;
- public int previewType;
- public String intent;
-
- // share completed fields
- public int targetType;
- public int positionPicked;
- public boolean isPinned;
-
- CallRecord(int atomId, UiEventLogger.UiEventEnum eventId,
- String packageName, InstanceId instanceId) {
- this.atomId = atomId;
- this.packageName = packageName;
- this.instanceId = instanceId;
- this.event = eventId;
- }
-
- CallRecord(int atomId, String packageName, InstanceId instanceId, String mimeType,
- int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType,
- String intent) {
- this.atomId = atomId;
- this.packageName = packageName;
- this.instanceId = instanceId;
- this.mimeType = mimeType;
- this.appProvidedDirect = appProvidedDirect;
- this.appProvidedApp = appProvidedApp;
- this.isWorkprofile = isWorkprofile;
- this.previewType = previewType;
- this.intent = intent;
- }
-
- CallRecord(int atomId, String packageName, InstanceId instanceId, int targetType,
- int positionPicked, boolean isPinned) {
- this.atomId = atomId;
- this.packageName = packageName;
- this.instanceId = instanceId;
- this.targetType = targetType;
- this.positionPicked = positionPicked;
- this.isPinned = isPinned;
- }
-
- }
- private List<CallRecord> mCalls = new ArrayList<>();
-
- public int numCalls() {
- return mCalls.size();
- }
-
- List<CallRecord> getCalls() {
- return mCalls;
- }
-
- CallRecord get(int index) {
- return mCalls.get(index);
- }
-
- UiEventLogger.UiEventEnum event(int index) {
- return mCalls.get(index).event;
- }
-
- public void removeCallsForUiEventsOfType(int uiEventType) {
- mCalls.removeIf(
- call ->
- (call.atomId == FrameworkStatsLog.UI_EVENT_REPORTED)
- && (call.event.getId() == uiEventType));
- }
-
- @Override
- public void logShareStarted(int eventId, String packageName, String mimeType,
- int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType,
- String intent) {
- mCalls.add(new CallRecord(FrameworkStatsLog.SHARESHEET_STARTED, packageName,
- getInstanceId(), mimeType, appProvidedDirect, appProvidedApp, isWorkprofile,
- previewType, intent));
- }
-
- @Override
- public void logShareTargetSelected(int targetType, String packageName, int positionPicked,
- boolean isPinned) {
- mCalls.add(new CallRecord(FrameworkStatsLog.RANKING_SELECTED, packageName, getInstanceId(),
- SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), positionPicked,
- isPinned));
- }
-
- @Override
- public void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) {
- mCalls.add(new CallRecord(FrameworkStatsLog.UI_EVENT_REPORTED,
- event, "", instanceId));
- }
-
- @Override
- public InstanceId getInstanceId() {
- return InstanceId.fakeInstanceId(-1);
- }
-}
diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
new file mode 100644
index 0000000..705a322
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java
@@ -0,0 +1,406 @@
+/*
+ * Copyright (C) 2020 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 com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.AdditionalMatchers.gt;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.content.Intent;
+import android.metrics.LogMaker;
+
+import com.android.intentresolver.ChooserActivityLogger.FrameworkStatsLogger;
+import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent;
+import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent;
+import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent;
+import com.android.internal.logging.InstanceId;
+import com.android.internal.logging.MetricsLogger;
+import com.android.internal.logging.UiEventLogger;
+import com.android.internal.logging.UiEventLogger.UiEventEnum;
+import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
+import com.android.internal.util.FrameworkStatsLog;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public final class ChooserActivityLoggerTest {
+ @Mock private UiEventLogger mUiEventLog;
+ @Mock private FrameworkStatsLogger mFrameworkLog;
+ @Mock private MetricsLogger mMetricsLogger;
+
+ private ChooserActivityLogger mChooserLogger;
+
+ @Before
+ public void setUp() {
+ //Mockito.reset(mUiEventLog, mFrameworkLog, mMetricsLogger);
+ mChooserLogger = new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger);
+ }
+
+ @After
+ public void tearDown() {
+ verifyNoMoreInteractions(mUiEventLog);
+ verifyNoMoreInteractions(mFrameworkLog);
+ verifyNoMoreInteractions(mMetricsLogger);
+ }
+
+ @Test
+ public void testLogChooserActivityShown_personalProfile() {
+ final boolean isWorkProfile = false;
+ final String mimeType = "application/TestType";
+ final long systemCost = 456;
+
+ mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost);
+
+ ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
+ verify(mMetricsLogger).write(eventCaptor.capture());
+ LogMaker event = eventCaptor.getValue();
+
+ assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN);
+ assertThat(event.getSubtype()).isEqualTo(MetricsEvent.PARENT_PROFILE);
+ assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType);
+ assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS))
+ .isEqualTo(systemCost);
+ }
+
+ @Test
+ public void testLogChooserActivityShown_workProfile() {
+ final boolean isWorkProfile = true;
+ final String mimeType = "application/TestType";
+ final long systemCost = 456;
+
+ mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost);
+
+ ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
+ verify(mMetricsLogger).write(eventCaptor.capture());
+ LogMaker event = eventCaptor.getValue();
+
+ assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN);
+ assertThat(event.getSubtype()).isEqualTo(MetricsEvent.MANAGED_PROFILE);
+ assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType);
+ assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS))
+ .isEqualTo(systemCost);
+ }
+
+ @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 String intentAction = Intent.ACTION_SENDTO;
+
+ mChooserLogger.logShareStarted(
+ eventId,
+ packageName,
+ mimeType,
+ appProvidedDirectTargets,
+ appProvidedAppTargets,
+ workProfile,
+ previewType,
+ intentAction);
+
+ verify(mFrameworkLog).write(
+ eq(FrameworkStatsLog.SHARESHEET_STARTED),
+ eq(SharesheetStartedEvent.SHARE_STARTED.getId()),
+ eq(packageName),
+ /* instanceId=*/ gt(0),
+ eq(mimeType),
+ eq(appProvidedDirectTargets),
+ eq(appProvidedAppTargets),
+ eq(workProfile),
+ eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE),
+ eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO));
+ }
+
+ @Test
+ public void testLogShareTargetSelected() {
+ final int targetType = ChooserActivityLogger.SELECTION_TYPE_SERVICE;
+ final String packageName = "com.test.foo";
+ final int positionPicked = 123;
+ final int directTargetAlsoRanked = -1;
+ final int callerTargetCount = 0;
+ final boolean isPinned = true;
+ final boolean isSuccessfullySelected = true;
+ final long selectionCost = 456;
+
+ mChooserLogger.logShareTargetSelected(
+ targetType,
+ packageName,
+ positionPicked,
+ directTargetAlsoRanked,
+ callerTargetCount,
+ /* directTargetHashed= */ null,
+ isPinned,
+ isSuccessfullySelected,
+ selectionCost);
+
+ verify(mFrameworkLog).write(
+ eq(FrameworkStatsLog.RANKING_SELECTED),
+ eq(SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId()),
+ eq(packageName),
+ /* instanceId=*/ gt(0),
+ eq(positionPicked),
+ eq(isPinned));
+
+ ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
+ verify(mMetricsLogger).write(eventCaptor.capture());
+ LogMaker event = eventCaptor.getValue();
+ assertThat(event.getCategory()).isEqualTo(
+ MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET);
+ assertThat(event.getSubtype()).isEqualTo(positionPicked);
+ }
+
+ @Test
+ public void testLogActionSelected() {
+ mChooserLogger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY);
+
+ verify(mFrameworkLog).write(
+ eq(FrameworkStatsLog.RANKING_SELECTED),
+ eq(SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()),
+ eq(""),
+ /* instanceId=*/ gt(0),
+ eq(-1),
+ eq(false));
+
+ ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
+ verify(mMetricsLogger).write(eventCaptor.capture());
+ LogMaker event = eventCaptor.getValue();
+ assertThat(event.getCategory()).isEqualTo(
+ MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET);
+ assertThat(event.getSubtype()).isEqualTo(1);
+ }
+
+ @Test
+ public void testLogDirectShareTargetReceived() {
+ final int category = MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER;
+ final int latency = 123;
+
+ mChooserLogger.logDirectShareTargetReceived(category, latency);
+
+ ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
+ verify(mMetricsLogger).write(eventCaptor.capture());
+ LogMaker event = eventCaptor.getValue();
+ assertThat(event.getCategory()).isEqualTo(category);
+ assertThat(event.getSubtype()).isEqualTo(latency);
+ }
+
+ @Test
+ public void testLogActionShareWithPreview() {
+ final int previewType = ChooserContentPreviewUi.CONTENT_PREVIEW_TEXT;
+
+ mChooserLogger.logActionShareWithPreview(previewType);
+
+ ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class);
+ verify(mMetricsLogger).write(eventCaptor.capture());
+ LogMaker event = eventCaptor.getValue();
+ assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_SHARE_WITH_PREVIEW);
+ assertThat(event.getSubtype()).isEqualTo(previewType);
+ }
+
+ @Test
+ public void testLogSharesheetTriggered() {
+ mChooserLogger.logSharesheetTriggered();
+ verify(mUiEventLog).logWithInstanceId(
+ eq(SharesheetStandardEvent.SHARESHEET_TRIGGERED), eq(0), isNull(), any());
+ }
+
+ @Test
+ public void testLogSharesheetAppLoadComplete() {
+ mChooserLogger.logSharesheetAppLoadComplete();
+ verify(mUiEventLog).logWithInstanceId(
+ eq(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE), eq(0), isNull(), any());
+ }
+
+ @Test
+ public void testLogSharesheetDirectLoadComplete() {
+ mChooserLogger.logSharesheetDirectLoadComplete();
+ verify(mUiEventLog).logWithInstanceId(
+ eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE),
+ eq(0),
+ isNull(),
+ any());
+ }
+
+ @Test
+ public void testLogSharesheetDirectLoadTimeout() {
+ mChooserLogger.logSharesheetDirectLoadTimeout();
+ verify(mUiEventLog).logWithInstanceId(
+ eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT), eq(0), isNull(), any());
+ }
+
+ @Test
+ public void testLogSharesheetProfileChanged() {
+ mChooserLogger.logSharesheetProfileChanged();
+ verify(mUiEventLog).logWithInstanceId(
+ eq(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED), eq(0), isNull(), any());
+ }
+
+ @Test
+ public void testLogSharesheetExpansionChanged_collapsed() {
+ mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ true);
+ verify(mUiEventLog).logWithInstanceId(
+ eq(SharesheetStandardEvent.SHARESHEET_COLLAPSED), eq(0), isNull(), any());
+ }
+
+ @Test
+ public void testLogSharesheetExpansionChanged_expanded() {
+ mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ false);
+ verify(mUiEventLog).logWithInstanceId(
+ eq(SharesheetStandardEvent.SHARESHEET_EXPANDED), eq(0), isNull(), any());
+ }
+
+ @Test
+ public void testLogSharesheetAppShareRankingTimeout() {
+ mChooserLogger.logSharesheetAppShareRankingTimeout();
+ verify(mUiEventLog).logWithInstanceId(
+ eq(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT),
+ eq(0),
+ isNull(),
+ any());
+ }
+
+ @Test
+ public void testLogSharesheetEmptyDirectShareRow() {
+ mChooserLogger.logSharesheetEmptyDirectShareRow();
+ verify(mUiEventLog).logWithInstanceId(
+ eq(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW),
+ eq(0),
+ isNull(),
+ any());
+ }
+
+ @Test
+ public void testDifferentLoggerInstancesUseDifferentInstanceIds() {
+ ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class);
+ ChooserActivityLogger chooserLogger2 =
+ new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger);
+
+ final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY;
+ final String packageName = "com.test.foo";
+ final int positionPicked = 123;
+ final int directTargetAlsoRanked = -1;
+ final int callerTargetCount = 0;
+ final boolean isPinned = true;
+ final boolean isSuccessfullySelected = true;
+ final long selectionCost = 456;
+
+ mChooserLogger.logShareTargetSelected(
+ targetType,
+ packageName,
+ positionPicked,
+ directTargetAlsoRanked,
+ callerTargetCount,
+ /* directTargetHashed= */ null,
+ isPinned,
+ isSuccessfullySelected,
+ selectionCost);
+
+ chooserLogger2.logShareTargetSelected(
+ targetType,
+ packageName,
+ positionPicked,
+ directTargetAlsoRanked,
+ callerTargetCount,
+ /* directTargetHashed= */ null,
+ isPinned,
+ isSuccessfullySelected,
+ selectionCost);
+
+ verify(mFrameworkLog, times(2)).write(
+ anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean());
+
+ int id1 = idIntCaptor.getAllValues().get(0);
+ int id2 = idIntCaptor.getAllValues().get(1);
+
+ assertThat(id1).isGreaterThan(0);
+ assertThat(id2).isGreaterThan(0);
+ assertThat(id1).isNotEqualTo(id2);
+ }
+
+ @Test
+ public void testUiAndFrameworkEventsUseSameInstanceIdForSameLoggerInstance() {
+ ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class);
+ ArgumentCaptor<InstanceId> idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class);
+
+ final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY;
+ final String packageName = "com.test.foo";
+ final int positionPicked = 123;
+ final int directTargetAlsoRanked = -1;
+ final int callerTargetCount = 0;
+ final boolean isPinned = true;
+ final boolean isSuccessfullySelected = true;
+ final long selectionCost = 456;
+
+ mChooserLogger.logShareTargetSelected(
+ targetType,
+ packageName,
+ positionPicked,
+ directTargetAlsoRanked,
+ callerTargetCount,
+ /* directTargetHashed= */ null,
+ isPinned,
+ isSuccessfullySelected,
+ selectionCost);
+
+ verify(mFrameworkLog).write(
+ anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean());
+
+ mChooserLogger.logSharesheetTriggered();
+ verify(mUiEventLog).logWithInstanceId(
+ any(UiEventEnum.class), anyInt(), any(), idObjectCaptor.capture());
+
+ assertThat(idIntCaptor.getValue()).isGreaterThan(0);
+ assertThat(idObjectCaptor.getValue().getId()).isEqualTo(idIntCaptor.getValue());
+ }
+
+ @Test
+ public void testTargetSelectionCategories() {
+ assertThat(ChooserActivityLogger.getTargetSelectionCategory(
+ ChooserActivityLogger.SELECTION_TYPE_SERVICE))
+ .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET);
+ assertThat(ChooserActivityLogger.getTargetSelectionCategory(
+ ChooserActivityLogger.SELECTION_TYPE_APP))
+ .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET);
+ assertThat(ChooserActivityLogger.getTargetSelectionCategory(
+ ChooserActivityLogger.SELECTION_TYPE_STANDARD))
+ .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET);
+ assertThat(ChooserActivityLogger.getTargetSelectionCategory(
+ ChooserActivityLogger.SELECTION_TYPE_COPY)).isEqualTo(0);
+ assertThat(ChooserActivityLogger.getTargetSelectionCategory(
+ ChooserActivityLogger.SELECTION_TYPE_NEARBY)).isEqualTo(0);
+ assertThat(ChooserActivityLogger.getTargetSelectionCategory(
+ ChooserActivityLogger.SELECTION_TYPE_EDIT)).isEqualTo(0);
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java
index 080f1e4..5df0d4a 100644
--- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java
+++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java
@@ -16,21 +16,28 @@
package com.android.intentresolver;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
-import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Bitmap;
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.internal.logging.MetricsLogger;
+import com.android.intentresolver.shortcuts.ShortcutLoader;
-import java.util.List;
+import java.util.function.Consumer;
import java.util.function.Function;
+import kotlin.jvm.functions.Function2;
+
/**
* Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing.
* We cannot directly mock the activity created since instrumentation creates it, so instead we use
@@ -49,7 +56,8 @@
@SuppressWarnings("Since15")
public Function<PackageManager, PackageManager> createPackageManager;
public Function<TargetInfo, Boolean> onSafelyStartCallback;
- public Function<ChooserListAdapter, Void> onQueryDirectShareTargets;
+ public Function2<UserHandle, Consumer<ShortcutLoader.Result>, ShortcutLoader>
+ shortcutLoaderFactory = (userHandle, callback) -> null;
public ResolverListController resolverListController;
public ResolverListController workResolverListController;
public Boolean isVoiceInteraction;
@@ -57,21 +65,20 @@
public Cursor resolverCursor;
public boolean resolverForceException;
public Bitmap previewThumbnail;
- public MetricsLogger metricsLogger;
public ChooserActivityLogger chooserActivityLogger;
public int alternateProfileSetting;
public Resources resources;
public UserHandle workProfileUserHandle;
public boolean hasCrossProfileIntents;
public boolean isQuietModeEnabled;
- public boolean isWorkProfileUserRunning;
- public boolean isWorkProfileUserUnlocked;
- public AbstractMultiProfilePagerAdapter.Injector multiPagerAdapterInjector;
+ public Integer myUserId;
+ public QuietModeManager mQuietModeManager;
+ public MyUserIdProvider mMyUserIdProvider;
+ public CrossProfileIntentsChecker mCrossProfileIntentsChecker;
public PackageManager packageManager;
public void reset() {
onSafelyStartCallback = null;
- onQueryDirectShareTargets = null;
isVoiceInteraction = null;
createPackageManager = null;
previewThumbnail = null;
@@ -80,23 +87,15 @@
resolverForceException = false;
resolverListController = mock(ResolverListController.class);
workResolverListController = mock(ResolverListController.class);
- metricsLogger = mock(MetricsLogger.class);
- chooserActivityLogger = new ChooserActivityLoggerFake();
+ chooserActivityLogger = mock(ChooserActivityLogger.class);
alternateProfileSetting = 0;
resources = null;
workProfileUserHandle = null;
hasCrossProfileIntents = true;
isQuietModeEnabled = false;
- isWorkProfileUserRunning = true;
- isWorkProfileUserUnlocked = true;
+ myUserId = null;
packageManager = null;
- multiPagerAdapterInjector = new AbstractMultiProfilePagerAdapter.Injector() {
- @Override
- public boolean hasCrossProfileIntents(List<Intent> intents, int sourceUserId,
- int targetUserId) {
- return hasCrossProfileIntents;
- }
-
+ mQuietModeManager = new QuietModeManager() {
@Override
public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
return isQuietModeEnabled;
@@ -107,7 +106,28 @@
UserHandle workProfileUserHandle) {
isQuietModeEnabled = enabled;
}
+
+ @Override
+ public void markWorkProfileEnabledBroadcastReceived() {
+ }
+
+ @Override
+ public boolean isWaitingToEnableWorkProfile() {
+ return false;
+ }
};
+ shortcutLoaderFactory = ((userHandle, resultConsumer) -> null);
+
+ mMyUserIdProvider = new MyUserIdProvider() {
+ @Override
+ public int getMyUserId() {
+ return myUserId != null ? myUserId : UserHandle.myUserId();
+ }
+ };
+
+ mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class);
+ when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt()))
+ .thenAnswer(invocation -> hasCrossProfileIntents);
}
private ChooserActivityOverrideData() {}
diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt
new file mode 100644
index 0000000..58f6b73
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt
@@ -0,0 +1,155 @@
+/*
+ * 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.content.ComponentName
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.ResolveInfoFlags
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.ChooserListAdapter.LoadDirectShareIconTask
+import com.android.intentresolver.chooser.DisplayResolveInfo
+import com.android.intentresolver.chooser.SelectableTargetInfo
+import com.android.intentresolver.chooser.TargetInfo
+import com.android.internal.R
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class ChooserListAdapterTest {
+ private val packageManager = mock<PackageManager> {
+ whenever(
+ resolveActivity(any(), any<ResolveInfoFlags>())
+ ).thenReturn(mock())
+ }
+ private val context = InstrumentationRegistry.getInstrumentation().getContext()
+ private val resolverListController = mock<ResolverListController>()
+ private val chooserActivityLogger = mock<ChooserActivityLogger>()
+
+ private fun createChooserListAdapter(
+ taskProvider: (TargetInfo?) -> LoadDirectShareIconTask
+ ) = object : ChooserListAdapter(
+ context,
+ emptyList(),
+ emptyArray(),
+ emptyList(),
+ false,
+ resolverListController,
+ null,
+ Intent(),
+ mock(),
+ packageManager,
+ chooserActivityLogger,
+ mock(),
+ 0
+ ) {
+ override fun createLoadDirectShareIconTask(
+ info: SelectableTargetInfo
+ ): LoadDirectShareIconTask = taskProvider(info)
+ }
+
+ @Before
+ fun setup() {
+ // ChooserListAdapter reads DeviceConfig and needs a permission for that.
+ InstrumentationRegistry
+ .getInstrumentation()
+ .getUiAutomation()
+ .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG")
+ }
+
+ @Test
+ fun testDirectShareTargetLoadingIconIsStarted() {
+ val view = createView()
+ val viewHolder = ResolverListAdapter.ViewHolder(view)
+ view.tag = viewHolder
+ val targetInfo = createSelectableTargetInfo()
+ val iconTask = mock<LoadDirectShareIconTask>()
+ val testSubject = createChooserListAdapter { iconTask }
+ testSubject.onBindView(view, targetInfo, 0)
+
+ verify(iconTask, times(1)).loadIcon()
+ }
+
+ @Test
+ fun testOnlyOneTaskPerTarget() {
+ val view = createView()
+ val viewHolderOne = ResolverListAdapter.ViewHolder(view)
+ view.tag = viewHolderOne
+ val targetInfo = createSelectableTargetInfo()
+ val iconTaskOne = mock<LoadDirectShareIconTask>()
+ val testTaskProvider = mock<() -> LoadDirectShareIconTask> {
+ whenever(invoke()).thenReturn(iconTaskOne)
+ }
+ val testSubject = createChooserListAdapter { testTaskProvider.invoke() }
+ testSubject.onBindView(view, targetInfo, 0)
+
+ val viewHolderTwo = ResolverListAdapter.ViewHolder(view)
+ view.tag = viewHolderTwo
+ whenever(testTaskProvider()).thenReturn(mock())
+
+ testSubject.onBindView(view, targetInfo, 0)
+
+ verify(iconTaskOne, times(1)).loadIcon()
+ verify(testTaskProvider, times(1)).invoke()
+ }
+
+ private fun createSelectableTargetInfo(): TargetInfo =
+ SelectableTargetInfo.newSelectableTargetInfo(
+ /* sourceInfo = */ DisplayResolveInfo.newDisplayResolveInfo(
+ Intent(),
+ ResolverDataProvider.createResolveInfo(2, 0),
+ "label",
+ "extended info",
+ Intent(),
+ /* resolveInfoPresentationGetter= */ null
+ ),
+ /* backupResolveInfo = */ mock(),
+ /* resolvedIntent = */ Intent(),
+ /* chooserTarget = */ createChooserTarget(
+ "Target", 0.5f, ComponentName("pkg", "Class"), "id-1"
+ ),
+ /* modifiedScore = */ 1f,
+ /* shortcutInfo = */ createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1),
+ /* appTarget */ null,
+ /* referrerFillInIntent = */ Intent()
+ )
+
+ private fun createView(): View {
+ val view = FrameLayout(context)
+ TextView(context).apply {
+ id = R.id.text1
+ view.addView(this)
+ }
+ TextView(context).apply {
+ id = R.id.text2
+ view.addView(this)
+ }
+ ImageView(context).apply {
+ id = R.id.icon
+ view.addView(this)
+ }
+ return view
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java
index 0e9f010..97de97f 100644
--- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java
+++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java
@@ -19,11 +19,13 @@
import static org.mockito.Mockito.when;
import android.annotation.Nullable;
+import android.app.prediction.AppPredictor;
import android.app.usage.UsageStatsManager;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
+import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Resources;
@@ -33,19 +35,18 @@
import android.os.UserHandle;
import android.util.Size;
-import com.android.intentresolver.AbstractMultiProfilePagerAdapter;
-import com.android.intentresolver.ChooserActivityLogger;
-import com.android.intentresolver.ChooserActivityOverrideData;
-import com.android.intentresolver.ChooserListAdapter;
-import com.android.intentresolver.IChooserWrapper;
-import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter;
-import com.android.intentresolver.ResolverListController;
+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.internal.logging.MetricsLogger;
+import com.android.intentresolver.grid.ChooserGridAdapter;
+import com.android.intentresolver.shortcuts.ShortcutLoader;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import java.util.List;
+import java.util.function.Consumer;
/**
* Simple wrapper around chooser activity to be able to initiate it under test. For more
@@ -64,25 +65,34 @@
}
@Override
- protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter(
- Intent[] initialIntents, List<ResolveInfo> rList, boolean filterLastUsed) {
- AbstractMultiProfilePagerAdapter multiProfilePagerAdapter =
- super.createMultiProfilePagerAdapter(initialIntents, rList, filterLastUsed);
- multiProfilePagerAdapter.setInjector(sOverrides.multiPagerAdapterInjector);
- return multiProfilePagerAdapter;
- }
-
- @Override
- public ChooserListAdapter createChooserListAdapter(Context context, List<Intent> payloadIntents,
- Intent[] initialIntents, List<ResolveInfo> rList, boolean filterLastUsed,
- ResolverListController resolverListController) {
+ public ChooserListAdapter createChooserListAdapter(
+ Context context,
+ List<Intent> payloadIntents,
+ Intent[] initialIntents,
+ List<ResolveInfo> rList,
+ boolean filterLastUsed,
+ ResolverListController resolverListController,
+ UserHandle userHandle,
+ Intent targetIntent,
+ ChooserRequestParameters chooserRequest,
+ int maxTargetsPerRow) {
PackageManager packageManager =
sOverrides.packageManager == null ? context.getPackageManager()
: sOverrides.packageManager;
- return new ChooserListAdapter(context, payloadIntents, initialIntents, rList,
- filterLastUsed, resolverListController,
- this, this, packageManager,
- getChooserActivityLogger());
+ return new ChooserListAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ this,
+ packageManager,
+ getChooserActivityLogger(),
+ chooserRequest,
+ maxTargetsPerRow);
}
@Override
@@ -119,7 +129,7 @@
@Override
protected TargetInfo getNearbySharingTarget(Intent originalIntent) {
- return new ChooserWrapperActivity.EmptyTargetInfo();
+ return NotSelectableTargetInfo.newEmptyTargetInfo();
}
@Override
@@ -139,6 +149,30 @@
}
@Override
+ protected MyUserIdProvider createMyUserIdProvider() {
+ if (sOverrides.mMyUserIdProvider != null) {
+ return sOverrides.mMyUserIdProvider;
+ }
+ return super.createMyUserIdProvider();
+ }
+
+ @Override
+ protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
+ if (sOverrides.mCrossProfileIntentsChecker != null) {
+ return sOverrides.mCrossProfileIntentsChecker;
+ }
+ return super.createCrossProfileIntentsChecker();
+ }
+
+ @Override
+ protected QuietModeManager createQuietModeManager() {
+ if (sOverrides.mQuietModeManager != null) {
+ return sOverrides.mQuietModeManager;
+ }
+ return super.createQuietModeManager();
+ }
+
+ @Override
public void safelyStartActivity(com.android.intentresolver.chooser.TargetInfo cti) {
if (sOverrides.onSafelyStartCallback != null
&& sOverrides.onSafelyStartCallback.apply(cti)) {
@@ -187,11 +221,6 @@
}
@Override
- protected MetricsLogger getMetricsLogger() {
- return sOverrides.metricsLogger;
- }
-
- @Override
public ChooserActivityLogger getChooserActivityLogger() {
return sOverrides.chooserActivityLogger;
}
@@ -220,8 +249,13 @@
@Override
public DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri,
CharSequence pLabel, CharSequence pInfo, Intent replacementIntent,
- @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) {
- return new DisplayResolveInfo(originalIntent, pri, pLabel, pInfo, replacementIntent,
+ @Nullable TargetPresentationGetter resolveInfoPresentationGetter) {
+ return DisplayResolveInfo.newDisplayResolveInfo(
+ originalIntent,
+ pri,
+ pLabel,
+ pInfo,
+ replacementIntent,
resolveInfoPresentationGetter);
}
@@ -242,32 +276,18 @@
}
@Override
- protected void queryDirectShareTargets(ChooserListAdapter adapter,
- boolean skipAppPredictionService) {
- if (sOverrides.onQueryDirectShareTargets != null) {
- sOverrides.onQueryDirectShareTargets.apply(adapter);
+ protected ShortcutLoader createShortcutLoader(
+ Context context,
+ AppPredictor appPredictor,
+ UserHandle userHandle,
+ IntentFilter targetIntentFilter,
+ Consumer<ShortcutLoader.Result> callback) {
+ ShortcutLoader shortcutLoader =
+ sOverrides.shortcutLoaderFactory.invoke(userHandle, callback);
+ if (shortcutLoader != null) {
+ return shortcutLoader;
}
- super.queryDirectShareTargets(adapter, skipAppPredictionService);
- }
-
- @Override
- protected boolean isQuietModeEnabled(UserHandle userHandle) {
- return sOverrides.isQuietModeEnabled;
- }
-
- @Override
- protected boolean isUserRunning(UserHandle userHandle) {
- if (userHandle.equals(UserHandle.SYSTEM)) {
- return super.isUserRunning(userHandle);
- }
- return sOverrides.isWorkProfileUserRunning;
- }
-
- @Override
- protected boolean isUserUnlocked(UserHandle userHandle) {
- if (userHandle.equals(UserHandle.SYSTEM)) {
- return super.isUserUnlocked(userHandle);
- }
- return sOverrides.isWorkProfileUserUnlocked;
+ return super.createShortcutLoader(
+ context, appPredictor, userHandle, targetIntentFilter, callback);
}
}
diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java
index f81cd02..af897a4 100644
--- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java
+++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java
@@ -22,9 +22,10 @@
import android.content.pm.ResolveInfo;
import android.os.UserHandle;
-import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter;
import com.android.intentresolver.chooser.DisplayResolveInfo;
+import java.util.concurrent.Executor;
+
/**
* Test-only extended API capabilities that an instrumented ChooserActivity subclass provides in
* order to expose the internals for override/inspection. Implementations should apply the overrides
@@ -38,7 +39,8 @@
UsageStatsManager getUsageStatsManager();
DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri,
CharSequence pLabel, CharSequence pInfo, Intent replacementIntent,
- @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter);
+ @Nullable TargetPresentationGetter resolveInfoPresentationGetter);
UserHandle getCurrentUserHandle();
ChooserActivityLogger getChooserActivityLogger();
+ Executor getMainExecutor();
}
diff --git a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt
new file mode 100644
index 0000000..159c6d6
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt
@@ -0,0 +1,146 @@
+/*
+ * 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
+
+/**
+ * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects
+ * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not
+ * be null"). To fix this, we can use methods that modify the return type to be nullable. This
+ * causes Kotlin to skip the null checks.
+ * Cloned from frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt
+ */
+
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatcher
+import org.mockito.Mockito
+import org.mockito.stubbing.OngoingStubbing
+
+/**
+ * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when
+ * null is returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+fun <T> eq(obj: T): T = Mockito.eq<T>(obj)
+
+/**
+ * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when
+ * null is returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
+inline fun <reified T> any(): T = any(T::class.java)
+
+/**
+ * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when
+ * null is returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher)
+
+/**
+ * Kotlin type-inferred version of Mockito.nullable()
+ */
+inline fun <reified T> nullable(): T? = Mockito.nullable(T::class.java)
+
+/**
+ * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException
+ * when null is returned.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
+
+/**
+ * Helper function for creating an argumentCaptor in kotlin.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> =
+ ArgumentCaptor.forClass(T::class.java)
+
+/**
+ * Helper function for creating new mocks, without the need to pass in a [Class] instance.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ *
+ * @param apply builder function to simplify stub configuration by improving type inference.
+ */
+inline fun <reified T : Any> mock(apply: T.() -> Unit = {}): T = Mockito.mock(T::class.java)
+ .apply(apply)
+
+/**
+ * Helper function for stubbing methods without the need to use backticks.
+ *
+ * @see Mockito.when
+ */
+fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall)
+
+/**
+ * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when
+ * kotlin tests are mocking kotlin objects and the methods take non-null parameters:
+ *
+ * java.lang.NullPointerException: capture() must not be null
+ */
+class KotlinArgumentCaptor<T> constructor(clazz: Class<T>) {
+ private val wrapped: ArgumentCaptor<T> = ArgumentCaptor.forClass(clazz)
+ fun capture(): T = wrapped.capture()
+ val value: T
+ get() = wrapped.value
+ val allValues: List<T>
+ get() = wrapped.allValues
+}
+
+/**
+ * Helper function for creating an argumentCaptor in kotlin.
+ *
+ * Generic T is nullable because implicitly bounded by Any?.
+ */
+inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> =
+ KotlinArgumentCaptor(T::class.java)
+
+/**
+ * Helper function for creating and using a single-use ArgumentCaptor in kotlin.
+ *
+ * val captor = argumentCaptor<Foo>()
+ * verify(...).someMethod(captor.capture())
+ * val captured = captor.value
+ *
+ * becomes:
+ *
+ * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) }
+ *
+ * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException.
+ */
+inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> Unit): T =
+ kotlinArgumentCaptor<T>().apply { block() }.value
+
+/**
+ * Variant of [withArgCaptor] for capturing multiple arguments.
+ *
+ * val captor = argumentCaptor<Foo>()
+ * verify(...).someMethod(captor.capture())
+ * val captured: List<Foo> = captor.allValues
+ *
+ * becomes:
+ *
+ * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) }
+ */
+inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> =
+ kotlinArgumentCaptor<T>().apply{ block() }.allValues
diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java
new file mode 100644
index 0000000..62c16ff
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java
@@ -0,0 +1,858 @@
+/*
+ * Copyright (C) 2016 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 androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.action.ViewActions.swipeUp;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.isEnabled;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import static com.android.intentresolver.MatcherUtils.first;
+import static com.android.intentresolver.ResolverWrapperActivity.sOverrides;
+
+import static org.hamcrest.CoreMatchers.allOf;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.fail;
+
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.espresso.Espresso;
+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;
+
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Resolver activity instrumentation tests
+ */
+@RunWith(AndroidJUnit4.class)
+public class ResolverActivityTest {
+ protected Intent getConcreteIntentForLaunch(Intent clientIntent) {
+ clientIntent.setClass(
+ androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(),
+ ResolverWrapperActivity.class);
+ return clientIntent;
+ }
+
+ @Rule
+ public ActivityTestRule<ResolverWrapperActivity> mActivityRule =
+ new ActivityTestRule<>(ResolverWrapperActivity.class, false, false);
+
+ @Before
+ public void setup() {
+ // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the
+ // permissions we require (which we'll read from the manifest at runtime).
+ androidx.test.platform.app.InstrumentationRegistry
+ .getInstrumentation()
+ .getUiAutomation()
+ .adoptShellPermissionIdentity();
+
+ sOverrides.reset();
+ }
+
+ @Test
+ public void twoOptionsAndUserSelectsOne() throws InterruptedException {
+ Intent sendIntent = createSendImageIntent();
+ List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+
+ when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource());
+ waitForIdle();
+
+ assertThat(activity.getAdapter().getCount(), is(2));
+
+ ResolveInfo[] chosen = new ResolveInfo[1];
+ sOverrides.onSafelyStartCallback = targetInfo -> {
+ chosen[0] = targetInfo.getResolveInfo();
+ return true;
+ };
+
+ ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0);
+ onView(withText(toChoose.activityInfo.name))
+ .perform(click());
+ onView(withId(R.id.button_once))
+ .perform(click());
+ waitForIdle();
+ assertThat(chosen[0], is(toChoose));
+ }
+
+ @Ignore // Failing - b/144929805
+ @Test
+ public void setMaxHeight() throws Exception {
+ Intent sendIntent = createSendImageIntent();
+ List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+
+ when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+ waitForIdle();
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ final View viewPager = activity.findViewById(R.id.profile_pager);
+ final int initialResolverHeight = viewPager.getHeight();
+
+ activity.runOnUiThread(() -> {
+ ResolverDrawerLayout layout = (ResolverDrawerLayout)
+ activity.findViewById(
+ R.id.contentPanel);
+ ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight
+ = initialResolverHeight - 1;
+ // Force a relayout
+ layout.invalidate();
+ layout.requestLayout();
+ });
+ waitForIdle();
+ assertThat("Drawer should be capped at maxHeight",
+ viewPager.getHeight() == (initialResolverHeight - 1));
+
+ activity.runOnUiThread(() -> {
+ ResolverDrawerLayout layout = (ResolverDrawerLayout)
+ activity.findViewById(
+ R.id.contentPanel);
+ ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight
+ = initialResolverHeight + 1;
+ // Force a relayout
+ layout.invalidate();
+ layout.requestLayout();
+ });
+ waitForIdle();
+ assertThat("Drawer should not change height if its height is less than maxHeight",
+ viewPager.getHeight() == initialResolverHeight);
+ }
+
+ @Ignore // Failing - b/144929805
+ @Test
+ public void setShowAtTopToTrue() throws Exception {
+ Intent sendIntent = createSendImageIntent();
+ List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+
+ when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+ waitForIdle();
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ final View viewPager = activity.findViewById(R.id.profile_pager);
+ final View divider = activity.findViewById(R.id.divider);
+ final RelativeLayout profileView =
+ (RelativeLayout) activity.findViewById(R.id.profile_button).getParent();
+ assertThat("Drawer should show at bottom by default",
+ profileView.getBottom() + divider.getHeight() == viewPager.getTop()
+ && profileView.getTop() > 0);
+
+ activity.runOnUiThread(() -> {
+ ResolverDrawerLayout layout = (ResolverDrawerLayout)
+ activity.findViewById(
+ R.id.contentPanel);
+ layout.setShowAtTop(true);
+ });
+ waitForIdle();
+ assertThat("Drawer should show at top with new attribute",
+ profileView.getBottom() + divider.getHeight() == viewPager.getTop()
+ && profileView.getTop() == 0);
+ }
+
+ @Test
+ public void hasLastChosenActivity() throws Exception {
+ Intent sendIntent = createSendImageIntent();
+ 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);
+ when(sOverrides.resolverListController.getLastChosen())
+ .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0));
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+
+ // The other entry is filtered to the last used slot
+ assertThat(activity.getAdapter().getCount(), is(1));
+ assertThat(activity.getAdapter().getPlaceholderCount(), is(1));
+
+ ResolveInfo[] chosen = new ResolveInfo[1];
+ sOverrides.onSafelyStartCallback = targetInfo -> {
+ chosen[0] = targetInfo.getResolveInfo();
+ return true;
+ };
+
+ onView(withId(R.id.button_once)).perform(click());
+ waitForIdle();
+ assertThat(chosen[0], is(toChoose));
+ }
+
+ @Test
+ public void hasOtherProfileOneOption() throws Exception {
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ markWorkProfileUserAvailable();
+
+ ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0);
+ Intent sendIntent = createSendImageIntent();
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource());
+ waitForIdle();
+
+ // The other entry is filtered to the last used slot
+ assertThat(activity.getAdapter().getCount(), is(1));
+
+ ResolveInfo[] chosen = new ResolveInfo[1];
+ sOverrides.onSafelyStartCallback = targetInfo -> {
+ chosen[0] = targetInfo.getResolveInfo();
+ return true;
+ };
+ // Make a stable copy of the components as the original list may be modified
+ List<ResolvedComponentInfo> stableCopy =
+ createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10);
+ // We pick the first one as there is another one in the work profile side
+ onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)))
+ .perform(click());
+ onView(withId(R.id.button_once))
+ .perform(click());
+ waitForIdle();
+ assertThat(chosen[0], is(toChoose));
+ }
+
+ @Test
+ public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception {
+ Intent sendIntent = createSendImageIntent();
+ List<ResolvedComponentInfo> resolvedComponentInfos =
+ 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);
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource());
+ waitForIdle();
+
+ // The other entry is filtered to the other profile slot
+ assertThat(activity.getAdapter().getCount(), is(2));
+
+ ResolveInfo[] chosen = new ResolveInfo[1];
+ sOverrides.onSafelyStartCallback = targetInfo -> {
+ chosen[0] = targetInfo.getResolveInfo();
+ return true;
+ };
+
+ // Confirm that the button bar is disabled by default
+ onView(withId(R.id.button_once)).check(matches(not(isEnabled())));
+
+ // Make a stable copy of the components as the original list may be modified
+ List<ResolvedComponentInfo> stableCopy =
+ createResolvedComponentsForTestWithOtherProfile(2);
+
+ onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))
+ .perform(click());
+ onView(withId(R.id.button_once)).perform(click());
+ waitForIdle();
+ assertThat(chosen[0], is(toChoose));
+ }
+
+
+ @Test
+ public void hasLastChosenActivityAndOtherProfile() throws Exception {
+ // In this case we prefer the other profile and don't display anything about the last
+ // chosen activity.
+ Intent sendIntent = createSendImageIntent();
+ List<ResolvedComponentInfo> resolvedComponentInfos =
+ 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);
+ when(sOverrides.resolverListController.getLastChosen())
+ .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0));
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource());
+ waitForIdle();
+
+ // The other entry is filtered to the other profile slot
+ assertThat(activity.getAdapter().getCount(), is(2));
+
+ ResolveInfo[] chosen = new ResolveInfo[1];
+ sOverrides.onSafelyStartCallback = targetInfo -> {
+ chosen[0] = targetInfo.getResolveInfo();
+ return true;
+ };
+
+ // Confirm that the button bar is disabled by default
+ onView(withId(R.id.button_once)).check(matches(not(isEnabled())));
+
+ // Make a stable copy of the components as the original list may be modified
+ List<ResolvedComponentInfo> stableCopy =
+ createResolvedComponentsForTestWithOtherProfile(2);
+
+ onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))
+ .perform(click());
+ onView(withId(R.id.button_once)).perform(click());
+ waitForIdle();
+ assertThat(chosen[0], is(toChoose));
+ }
+
+ @Test
+ public void testWorkTab_displayedWhenWorkProfileUserAvailable() {
+ Intent sendIntent = createSendImageIntent();
+ markWorkProfileUserAvailable();
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+
+ onView(withId(R.id.tabs)).check(matches(isDisplayed()));
+ }
+
+ @Test
+ public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() {
+ Intent sendIntent = createSendImageIntent();
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+
+ onView(withId(R.id.tabs)).check(matches(not(isDisplayed())));
+ }
+
+ @Test
+ public void testWorkTab_workTabListPopulatedBeforeGoingToTab() throws InterruptedException {
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos,
+ new ArrayList<>(workResolvedComponentInfos));
+ Intent sendIntent = createSendImageIntent();
+ markWorkProfileUserAvailable();
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+
+ assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0));
+ // The work list adapter must be populated in advance before tapping the other tab
+ assertThat(activity.getWorkListAdapter().getCount(), is(4));
+ }
+
+ @Test
+ public void testWorkTab_workTabUsesExpectedAdapter() {
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ markWorkProfileUserAvailable();
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withText(R.string.resolver_work_tab)).perform(click());
+
+ assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10));
+ assertThat(activity.getWorkListAdapter().getCount(), is(4));
+ }
+
+ @Test
+ public void testWorkTab_personalTabUsesExpectedAdapter() {
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(3);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ markWorkProfileUserAvailable();
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withText(R.string.resolver_work_tab)).perform(click());
+
+ assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10));
+ assertThat(activity.getPersonalListAdapter().getCount(), is(2));
+ }
+
+ @Test
+ public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+
+ onView(withText(R.string.resolver_work_tab))
+ .perform(click());
+ waitForIdle();
+ assertThat(activity.getWorkListAdapter().getCount(), is(4));
+ }
+
+ @Test
+ public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ ResolveInfo[] chosen = new ResolveInfo[1];
+ sOverrides.onSafelyStartCallback = targetInfo -> {
+ chosen[0] = targetInfo.getResolveInfo();
+ return true;
+ };
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withText(R.string.resolver_work_tab))
+ .perform(click());
+ waitForIdle();
+ onView(first(allOf(withText(workResolvedComponentInfos.get(0)
+ .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed())))
+ .perform(click());
+ onView(withId(R.id.button_once))
+ .perform(click());
+
+ waitForIdle();
+ assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0)));
+ }
+
+ @Test
+ public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets()
+ throws InterruptedException {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(1);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withText(R.string.resolver_work_tab))
+ .perform(click());
+
+ waitForIdle();
+ assertThat(activity.getWorkListAdapter().getCount(), is(4));
+ }
+
+ @Test
+ public void testWorkTab_headerIsVisibleInPersonalTab() {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(1);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createOpenWebsiteIntent();
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ TextView headerText = activity.findViewById(R.id.title);
+ String initialText = headerText.getText().toString();
+ assertFalse(initialText.isEmpty(), "Header text is empty.");
+ assertThat(headerText.getVisibility(), is(View.VISIBLE));
+ }
+
+ @Test
+ public void testWorkTab_switchTabs_headerStaysSame() {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(1);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createOpenWebsiteIntent();
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ TextView headerText = activity.findViewById(R.id.title);
+ String initialText = headerText.getText().toString();
+ onView(withText(R.string.resolver_work_tab))
+ .perform(click());
+
+ waitForIdle();
+ String currentText = headerText.getText().toString();
+ assertThat(headerText.getVisibility(), is(View.VISIBLE));
+ assertThat(String.format("Header text is not the same when switching tabs, personal profile"
+ + " header was %s but work profile header is %s", initialText, currentText),
+ TextUtils.equals(initialText, currentText));
+ }
+
+ @Test
+ public void testWorkTab_noPersonalApps_canStartWorkApps()
+ throws InterruptedException {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10);
+ List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ ResolveInfo[] chosen = new ResolveInfo[1];
+ sOverrides.onSafelyStartCallback = targetInfo -> {
+ chosen[0] = targetInfo.getResolveInfo();
+ return true;
+ };
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withText(R.string.resolver_work_tab))
+ .perform(click());
+ waitForIdle();
+ onView(first(allOf(
+ withText(workResolvedComponentInfos.get(0)
+ .getResolveInfoAt(0).activityInfo.applicationInfo.name),
+ isDisplayed())))
+ .perform(click());
+ onView(withId(R.id.button_once))
+ .perform(click());
+ waitForIdle();
+
+ assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0)));
+ }
+
+ @Test
+ public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() {
+ markWorkProfileUserAvailable();
+ int workProfileTargets = 4;
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(workProfileTargets);
+ sOverrides.hasCrossProfileIntents = false;
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ sendIntent.setType("TestType");
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withText(R.string.resolver_work_tab)).perform(click());
+ waitForIdle();
+ onView(withId(R.id.contentPanel))
+ .perform(swipeUp());
+
+ onView(withText(R.string.resolver_cross_profile_blocked))
+ .check(matches(isDisplayed()));
+ }
+
+ @Test
+ public void testWorkTab_workProfileDisabled_emptyStateShown() {
+ markWorkProfileUserAvailable();
+ int workProfileTargets = 4;
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(workProfileTargets);
+ sOverrides.isQuietModeEnabled = true;
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ sendIntent.setType("TestType");
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withId(R.id.contentPanel))
+ .perform(swipeUp());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
+ waitForIdle();
+
+ onView(withText(R.string.resolver_turn_on_work_apps))
+ .check(matches(isDisplayed()));
+ }
+
+ @Test
+ public void testWorkTab_noWorkAppsAvailable_emptyStateShown() {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTest(3);
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(0);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ sendIntent.setType("TestType");
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withId(R.id.contentPanel))
+ .perform(swipeUp());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
+ waitForIdle();
+
+ onView(withText(R.string.resolver_no_work_apps_available))
+ .check(matches(isDisplayed()));
+ }
+
+ @Test
+ public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTest(3);
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(0);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ sendIntent.setType("TestType");
+ sOverrides.isQuietModeEnabled = true;
+ sOverrides.hasCrossProfileIntents = false;
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withId(R.id.contentPanel))
+ .perform(swipeUp());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
+ waitForIdle();
+
+ onView(withText(R.string.resolver_cross_profile_blocked))
+ .check(matches(isDisplayed()));
+ }
+
+ @Test
+ public void testMiniResolver() {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTest(1);
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(1);
+ // Personal profile only has a browser
+ personalResolvedComponentInfos.get(0).getResolveInfoAt(0).handleAllWebDataURI = true;
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ sendIntent.setType("TestType");
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withId(R.id.open_cross_profile)).check(matches(isDisplayed()));
+ }
+
+ @Test
+ public void testMiniResolver_noCurrentProfileTarget() {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTest(0);
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(1);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ sendIntent.setType("TestType");
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+
+ // Need to ensure mini resolver doesn't trigger here.
+ assertNotMiniResolver();
+ }
+
+ private void assertNotMiniResolver() {
+ try {
+ onView(withId(R.id.open_cross_profile)).check(matches(isDisplayed()));
+ } catch (NoMatchingViewException e) {
+ return;
+ }
+ fail("Mini resolver present but shouldn't be");
+ }
+
+ @Test
+ public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() {
+ markWorkProfileUserAvailable();
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTest(3);
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(0);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ sendIntent.setType("TestType");
+ sOverrides.isQuietModeEnabled = true;
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+ onView(withId(R.id.contentPanel))
+ .perform(swipeUp());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
+ waitForIdle();
+
+ onView(withText(R.string.resolver_no_work_apps_available))
+ .check(matches(isDisplayed()));
+ }
+
+ @Test
+ public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_autolaunch() {
+ markWorkProfileUserAvailable();
+ int workProfileTargets = 4;
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(workProfileTargets);
+ sOverrides.hasCrossProfileIntents = false;
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ Intent sendIntent = createSendImageIntent();
+ sendIntent.setType("TestType");
+ ResolveInfo[] chosen = new ResolveInfo[1];
+ sOverrides.onSafelyStartCallback = targetInfo -> {
+ chosen[0] = targetInfo.getResolveInfo();
+ return true;
+ };
+
+ mActivityRule.launchActivity(sendIntent);
+ waitForIdle();
+
+ assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0)));
+ }
+
+ @Test
+ public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException {
+ markWorkProfileUserAvailable();
+
+ // In this case we prefer the other profile and don't display anything about the last
+ // chosen activity.
+ Intent sendIntent = createSendImageIntent();
+ List<ResolvedComponentInfo> resolvedComponentInfos =
+ createResolvedComponentsForTest(2);
+
+ when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
+ when(sOverrides.resolverListController.getLastChosen())
+ .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0));
+
+ final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent);
+ Espresso.registerIdlingResources(activity.getAdapter().getLabelIdlingResource());
+ waitForIdle();
+
+ // The other entry is filtered to the last used slot
+ assertThat(activity.getAdapter().hasFilteredItem(), is(false));
+ assertThat(activity.getAdapter().getCount(), is(2));
+ assertThat(activity.getAdapter().getPlaceholderCount(), is(2));
+ }
+
+ private Intent createSendImageIntent() {
+ Intent sendIntent = new Intent();
+ sendIntent.setAction(Intent.ACTION_SEND);
+ sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending");
+ sendIntent.setType("image/jpeg");
+ return sendIntent;
+ }
+
+ private Intent createOpenWebsiteIntent() {
+ Intent sendIntent = new Intent();
+ sendIntent.setAction(Intent.ACTION_VIEW);
+ sendIntent.setData(Uri.parse("https://google.com"));
+ return sendIntent;
+ }
+
+ private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults) {
+ List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
+ for (int i = 0; i < numberOfResults; i++) {
+ infoList.add(ResolverDataProvider.createResolvedComponentInfo(i));
+ }
+ return infoList;
+ }
+
+ private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile(
+ int numberOfResults) {
+ List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
+ for (int i = 0; i < numberOfResults; i++) {
+ if (i == 0) {
+ infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i));
+ } else {
+ infoList.add(ResolverDataProvider.createResolvedComponentInfo(i));
+ }
+ }
+ return infoList;
+ }
+
+ private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile(
+ int numberOfResults, int userId) {
+ List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
+ for (int i = 0; i < numberOfResults; i++) {
+ if (i == 0) {
+ infoList.add(
+ ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId));
+ } else {
+ infoList.add(ResolverDataProvider.createResolvedComponentInfo(i));
+ }
+ }
+ return infoList;
+ }
+
+ private void waitForIdle() {
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ }
+
+ private void markWorkProfileUserAvailable() {
+ ResolverWrapperActivity.sOverrides.workProfileUserHandle = UserHandle.of(10);
+ }
+
+ private void setupResolverControllers(
+ List<ResolvedComponentInfo> personalResolvedComponentInfos,
+ List<ResolvedComponentInfo> workResolvedComponentInfos) {
+ when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.isA(List.class)))
+ .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+ when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ 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));
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java
index 33e7123..fb928e0 100644
--- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java
+++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java
@@ -32,7 +32,7 @@
/**
* Utility class used by resolver tests to create mock data
*/
-class ResolverDataProvider {
+public class ResolverDataProvider {
static private int USER_SOMEONE_ELSE = 10;
@@ -52,12 +52,12 @@
createResolverIntent(i), createResolveInfo(i, userId));
}
- static ComponentName createComponentName(int i) {
+ public static ComponentName createComponentName(int i) {
final String name = "component" + i;
return new ComponentName("foo.bar." + name, name);
}
- static ResolveInfo createResolveInfo(int i, int userId) {
+ public static ResolveInfo createResolveInfo(int i, int userId) {
final ResolveInfo resolveInfo = new ResolveInfo();
resolveInfo.activityInfo = createActivityInfo(i);
resolveInfo.targetUserId = userId;
@@ -93,11 +93,17 @@
public String setResolveInfoLabel;
}
+ /** Create a {@link PackageManagerMockedInfo} with all distinct labels. */
static PackageManagerMockedInfo createPackageManagerMockedInfo(boolean hasOverridePermission) {
- final String appLabel = "app_label";
- final String activityLabel = "activity_label";
- final String resolveInfoLabel = "resolve_info_label";
+ return createPackageManagerMockedInfo(
+ hasOverridePermission, "app_label", "activity_label", "resolve_info_label");
+ }
+ static PackageManagerMockedInfo createPackageManagerMockedInfo(
+ boolean hasOverridePermission,
+ String appLabel,
+ String activityLabel,
+ String resolveInfoLabel) {
MockContext ctx = new MockContext() {
@Override
public PackageManager getPackageManager() {
diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java
new file mode 100644
index 0000000..239bffe
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright (C) 2017 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 org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.app.usage.UsageStatsManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Bundle;
+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;
+import java.util.function.Function;
+
+/*
+ * Simple wrapper around chooser activity to be able to initiate it under test
+ */
+public class ResolverWrapperActivity extends ResolverActivity {
+ static final OverrideData sOverrides = new OverrideData();
+ private UsageStatsManager mUsm;
+
+ public ResolverWrapperActivity() {
+ super(/* isIntentPicker= */ true);
+ }
+
+ // ResolverActivity inspects the launched-from UID at onCreate and needs to see some
+ // non-negative value in the test.
+ @Override
+ public int getLaunchedFromUid() {
+ return 1234;
+ }
+
+ @Override
+ public ResolverListAdapter createResolverListAdapter(Context context,
+ List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList,
+ boolean filterLastUsed, UserHandle userHandle) {
+ return new ResolverWrapperAdapter(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ createListController(userHandle),
+ userHandle,
+ payloadIntents.get(0), // TODO: extract upstream
+ this);
+ }
+
+ @Override
+ protected MyUserIdProvider createMyUserIdProvider() {
+ if (sOverrides.mMyUserIdProvider != null) {
+ return sOverrides.mMyUserIdProvider;
+ }
+ return super.createMyUserIdProvider();
+ }
+
+ @Override
+ protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() {
+ if (sOverrides.mCrossProfileIntentsChecker != null) {
+ return sOverrides.mCrossProfileIntentsChecker;
+ }
+ return super.createCrossProfileIntentsChecker();
+ }
+
+ @Override
+ protected QuietModeManager createQuietModeManager() {
+ if (sOverrides.mQuietModeManager != null) {
+ return sOverrides.mQuietModeManager;
+ }
+ return super.createQuietModeManager();
+ }
+
+ ResolverWrapperAdapter getAdapter() {
+ return (ResolverWrapperAdapter) mMultiProfilePagerAdapter.getActiveListAdapter();
+ }
+
+ ResolverListAdapter getPersonalListAdapter() {
+ return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0));
+ }
+
+ ResolverListAdapter getWorkListAdapter() {
+ if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) {
+ return null;
+ }
+ return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1));
+ }
+
+ @Override
+ public boolean isVoiceInteraction() {
+ if (sOverrides.isVoiceInteraction != null) {
+ return sOverrides.isVoiceInteraction;
+ }
+ return super.isVoiceInteraction();
+ }
+
+ @Override
+ public void safelyStartActivity(TargetInfo cti) {
+ if (sOverrides.onSafelyStartCallback != null &&
+ sOverrides.onSafelyStartCallback.apply(cti)) {
+ return;
+ }
+ super.safelyStartActivity(cti);
+ }
+
+ @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;
+ }
+
+ @Override
+ public PackageManager getPackageManager() {
+ if (sOverrides.createPackageManager != null) {
+ return sOverrides.createPackageManager.apply(super.getPackageManager());
+ }
+ return super.getPackageManager();
+ }
+
+ protected UserHandle getCurrentUserHandle() {
+ return mMultiProfilePagerAdapter.getCurrentUserHandle();
+ }
+
+ @Override
+ protected UserHandle getWorkProfileUserHandle() {
+ return sOverrides.workProfileUserHandle;
+ }
+
+ @Override
+ public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) {
+ super.startActivityAsUser(intent, options, user);
+ }
+
+ /**
+ * We cannot directly mock the activity created since instrumentation creates it.
+ * <p>
+ * Instead, we use static instances of this object to modify behavior.
+ */
+ static class OverrideData {
+ @SuppressWarnings("Since15")
+ public Function<PackageManager, PackageManager> createPackageManager;
+ public Function<TargetInfo, Boolean> onSafelyStartCallback;
+ public ResolverListController resolverListController;
+ public ResolverListController workResolverListController;
+ public Boolean isVoiceInteraction;
+ public UserHandle workProfileUserHandle;
+ public Integer myUserId;
+ public boolean hasCrossProfileIntents;
+ public boolean isQuietModeEnabled;
+ public QuietModeManager mQuietModeManager;
+ public MyUserIdProvider mMyUserIdProvider;
+ public CrossProfileIntentsChecker mCrossProfileIntentsChecker;
+
+ public void reset() {
+ onSafelyStartCallback = null;
+ isVoiceInteraction = null;
+ createPackageManager = null;
+ resolverListController = mock(ResolverListController.class);
+ workResolverListController = mock(ResolverListController.class);
+ workProfileUserHandle = null;
+ myUserId = null;
+ hasCrossProfileIntents = true;
+ isQuietModeEnabled = false;
+
+ mQuietModeManager = new QuietModeManager() {
+ @Override
+ public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) {
+ return isQuietModeEnabled;
+ }
+
+ @Override
+ public void requestQuietModeEnabled(boolean enabled,
+ UserHandle workProfileUserHandle) {
+ isQuietModeEnabled = enabled;
+ }
+
+ @Override
+ public void markWorkProfileEnabledBroadcastReceived() {
+ }
+
+ @Override
+ public boolean isWaitingToEnableWorkProfile() {
+ return false;
+ }
+ };
+
+ mMyUserIdProvider = new MyUserIdProvider() {
+ @Override
+ public int getMyUserId() {
+ return myUserId != null ? myUserId : UserHandle.myUserId();
+ }
+ };
+
+ mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class);
+ when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt()))
+ .thenAnswer(invocation -> hasCrossProfileIntents);
+ }
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java b/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java
new file mode 100644
index 0000000..a53b41d
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ResolverWrapperAdapter.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2019 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.pm.ResolveInfo;
+import android.os.UserHandle;
+
+import androidx.test.espresso.idling.CountingIdlingResource;
+
+import com.android.intentresolver.chooser.DisplayResolveInfo;
+
+import java.util.List;
+
+public class ResolverWrapperAdapter extends ResolverListAdapter {
+
+ private CountingIdlingResource mLabelIdlingResource =
+ new CountingIdlingResource("LoadLabelTask");
+
+ public ResolverWrapperAdapter(
+ Context context,
+ List<Intent> payloadIntents,
+ Intent[] initialIntents,
+ List<ResolveInfo> rList,
+ boolean filterLastUsed,
+ ResolverListController resolverListController,
+ UserHandle userHandle,
+ Intent targetIntent,
+ ResolverListCommunicator resolverListCommunicator) {
+ super(
+ context,
+ payloadIntents,
+ initialIntents,
+ rList,
+ filterLastUsed,
+ resolverListController,
+ userHandle,
+ targetIntent,
+ resolverListCommunicator,
+ false);
+ }
+
+ public CountingIdlingResource getLabelIdlingResource() {
+ return mLabelIdlingResource;
+ }
+
+ @Override
+ protected LoadLabelTask createLoadLabelTask(DisplayResolveInfo info) {
+ return new LoadLabelWrapperTask(info);
+ }
+
+ class LoadLabelWrapperTask extends LoadLabelTask {
+
+ protected LoadLabelWrapperTask(DisplayResolveInfo dri) {
+ super(dri);
+ }
+
+ @Override
+ protected void onPreExecute() {
+ mLabelIdlingResource.increment();
+ }
+
+ @Override
+ protected void onPostExecute(CharSequence[] result) {
+ super.onPostExecute(result);
+ mLabelIdlingResource.decrement();
+ }
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt
new file mode 100644
index 0000000..a8d6f97
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt
@@ -0,0 +1,308 @@
+/*
+ * 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.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ResolveInfo
+import android.content.pm.ShortcutInfo
+import android.service.chooser.ChooserTarget
+import com.android.intentresolver.chooser.DisplayResolveInfo
+import com.android.intentresolver.chooser.TargetInfo
+import androidx.test.filters.SmallTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+private const val PACKAGE_A = "package.a"
+private const val PACKAGE_B = "package.b"
+private const val CLASS_NAME = "./MainActivity"
+
+@SmallTest
+class ShortcutSelectionLogicTest {
+ private val packageTargets = HashMap<String, Array<ChooserTarget>>().apply {
+ arrayOf(PACKAGE_A, PACKAGE_B).forEach { pkg ->
+ // shortcuts in reverse priority order
+ val targets = Array(3) { i ->
+ createChooserTarget(
+ "Shortcut $i",
+ (i + 1).toFloat() / 10f,
+ ComponentName(pkg, CLASS_NAME),
+ pkg.shortcutId(i),
+ )
+ }
+ this[pkg] = targets
+ }
+ }
+
+ private val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo(
+ Intent(),
+ ResolverDataProvider.createResolveInfo(3, 0),
+ "label",
+ "extended info",
+ Intent(),
+ /* resolveInfoPresentationGetter= */ null)
+
+ private val otherBaseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo(
+ Intent(),
+ ResolverDataProvider.createResolveInfo(4, 0),
+ "label 2",
+ "extended info 2",
+ Intent(),
+ /* resolveInfoPresentationGetter= */ null)
+
+ private operator fun Map<String, Array<ChooserTarget>>.get(pkg: String, idx: Int) =
+ this[pkg]?.get(idx) ?: error("missing package $pkg")
+
+ @Test
+ fun testAddShortcuts_no_limits() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val testSubject = ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ false
+ )
+
+ val isUpdated = testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1, sc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults
+ )
+
+ assertTrue("Updates are expected", isUpdated)
+ assertShortcutsInOrder(
+ listOf(sc2, sc1),
+ serviceResults,
+ "Two shortcuts are expected as we do not apply per-app shortcut limit"
+ )
+ }
+
+ @Test
+ fun testAddShortcuts_same_package_with_per_package_limit() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val testSubject = ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ true
+ )
+
+ val isUpdated = testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1, sc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults
+ )
+
+ assertTrue("Updates are expected", isUpdated)
+ assertShortcutsInOrder(
+ listOf(sc2),
+ serviceResults,
+ "One shortcut is expected as we apply per-app shortcut limit"
+ )
+ }
+
+ @Test
+ fun testAddShortcuts_same_package_no_per_app_limit_with_target_limit() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val testSubject = ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ false
+ )
+
+ val isUpdated = testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1, sc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 1,
+ /* serviceTargets = */ serviceResults
+ )
+
+ assertTrue("Updates are expected", isUpdated)
+ assertShortcutsInOrder(
+ listOf(sc2),
+ serviceResults,
+ "One shortcut is expected as we apply overall shortcut limit"
+ )
+ }
+
+ @Test
+ fun testAddShortcuts_different_packages_with_per_package_limit() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val pkgAsc1 = packageTargets[PACKAGE_A, 0]
+ val pkgAsc2 = packageTargets[PACKAGE_A, 1]
+ val pkgBsc1 = packageTargets[PACKAGE_B, 0]
+ val pkgBsc2 = packageTargets[PACKAGE_B, 1]
+ val testSubject = ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ true
+ )
+
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(pkgAsc1, pkgAsc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults
+ )
+ testSubject.addServiceResults(
+ /* origTarget = */ otherBaseDisplayInfo,
+ /* origTargetScore = */ 0.2f,
+ /* targets = */ listOf(pkgBsc1, pkgBsc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults
+ )
+
+ assertShortcutsInOrder(
+ listOf(pkgBsc2, pkgAsc2),
+ serviceResults,
+ "Two shortcuts are expected as we apply per-app shortcut limit"
+ )
+ }
+
+ @Test
+ fun testAddShortcuts_pinned_shortcut() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val testSubject = ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ false
+ )
+
+ val isUpdated = testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0.1f,
+ /* targets = */ listOf(sc1, sc2),
+ /* isShortcutResult = */ true,
+ /* directShareToShortcutInfos = */ mapOf(
+ sc1 to createShortcutInfo(
+ PACKAGE_A.shortcutId(1),
+ sc1.componentName, 1).apply {
+ addFlags(ShortcutInfo.FLAG_PINNED)
+ }
+ ),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ mock(),
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults
+ )
+
+ assertTrue("Updates are expected", isUpdated)
+ assertShortcutsInOrder(
+ listOf(sc1, sc2),
+ serviceResults,
+ "Two shortcuts are expected as we do not apply per-app shortcut limit"
+ )
+ }
+
+ @Test
+ fun test_available_caller_shortcuts_count_is_limited() {
+ val serviceResults = ArrayList<TargetInfo>()
+ val sc1 = packageTargets[PACKAGE_A, 0]
+ val sc2 = packageTargets[PACKAGE_A, 1]
+ val sc3 = packageTargets[PACKAGE_A, 2]
+ val testSubject = ShortcutSelectionLogic(
+ /* maxShortcutTargetsPerApp = */ 1,
+ /* applySharingAppLimits = */ true
+ )
+ val context = mock<Context> {
+ whenever(packageManager).thenReturn(mock())
+ }
+
+ testSubject.addServiceResults(
+ /* origTarget = */ baseDisplayInfo,
+ /* origTargetScore = */ 0f,
+ /* targets = */ listOf(sc1, sc2, sc3),
+ /* isShortcutResult = */ false,
+ /* directShareToShortcutInfos = */ emptyMap(),
+ /* directShareToAppTargets = */ emptyMap(),
+ /* userContext = */ context,
+ /* targetIntent = */ mock(),
+ /* refererFillInIntent = */ mock(),
+ /* maxRankedTargets = */ 4,
+ /* serviceTargets = */ serviceResults
+ )
+
+ assertShortcutsInOrder(
+ listOf(sc3, sc2),
+ serviceResults,
+ "At most two caller-provided shortcuts are allowed"
+ )
+ }
+
+ // TODO: consider renaming. Not all `ChooserTarget`s are "shortcuts" and many of our test cases
+ // add results with `isShortcutResult = false` and `directShareToShortcutInfos = emptyMap()`.
+ private fun assertShortcutsInOrder(
+ expected: List<ChooserTarget>, actual: List<TargetInfo>, msg: String? = ""
+ ) {
+ assertEquals(msg, expected.size, actual.size)
+ for (i in expected.indices) {
+ assertEquals(
+ "Unexpected item at position $i",
+ expected[i].componentName,
+ actual[i].chooserTargetComponentName
+ )
+ assertEquals(
+ "Unexpected item at position $i",
+ expected[i].title,
+ actual[i].displayLabel
+ )
+ }
+ }
+
+ private fun String.shortcutId(id: Int) = "$this.$id"
+}
diff --git a/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt b/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt
new file mode 100644
index 0000000..e62672a
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt
@@ -0,0 +1,204 @@
+/*
+ * 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 com.android.intentresolver.ResolverDataProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+/**
+ * Unit tests for the various implementations of {@link TargetPresentationGetter}.
+ * TODO: consider expanding to cover icon logic (not just labels/sublabels).
+ * TODO: these are conceptually "acceptance tests" that provide comprehensive coverage of the
+ * apparent variations in the legacy implementation. The tests probably don't have to be so
+ * exhaustive if we're able to impose a simpler design on the implementation.
+ */
+class TargetPresentationGetterTest {
+ fun makeResolveInfoPresentationGetter(
+ withSubstitutePermission: Boolean,
+ appLabel: String,
+ activityLabel: String,
+ resolveInfoLabel: String): TargetPresentationGetter {
+ val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo(
+ withSubstitutePermission, appLabel, activityLabel, resolveInfoLabel)
+ val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100)
+ return factory.makePresentationGetter(testPackageInfo.resolveInfo)
+ }
+
+ fun makeActivityInfoPresentationGetter(
+ withSubstitutePermission: Boolean,
+ appLabel: String?,
+ activityLabel: String?): TargetPresentationGetter {
+ val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo(
+ withSubstitutePermission, appLabel, activityLabel, "")
+ val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100)
+ return factory.makePresentationGetter(testPackageInfo.activityInfo)
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(
+ false, "app_label", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label")
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(
+ false, "app_label", "app_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // Without the substitute permission, there's no logic to dedupe the labels.
+ // TODO: this matches our observations in the legacy code, but is it the right behavior? It
+ // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in
+ // the UI at least, but maybe that logic should be pulled back to the "presentation"?
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label")
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_nullRequestedLabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(false, null, "activity_label")
+ assertThat(presentationGetter.getLabel()).isNull()
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label")
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedLabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(false, "", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("")
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label")
+ }
+
+ @Test
+ fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(false, "app_label", "")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // Without the substitute permission, empty sublabels are passed through as-is.
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("")
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(
+ true, "app_label", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ // With the substitute permission, the same ("activity") label is requested as both the label
+ // and sublabel, even though the other value ("app_label") was distinct. Thus this behaves the
+ // same as a dupe.
+ assertThat(presentationGetter.getSubLabel()).isEqualTo(null)
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(
+ true, "app_label", "app_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // With the substitute permission, duped sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_nullRequestedLabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", null)
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // With the substitute permission, null inputs are a special case that produces null outputs
+ // (i.e., they're not simply passed-through from the inputs).
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedLabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", "")
+ // Empty "labels" are taken as-is and (unlike nulls) don't prompt a fallback to the sublabel.
+ // Thus (as in the previous case with substitute permission & "distinct" labels), this is
+ // treated as a dupe.
+ assertThat(presentationGetter.getLabel()).isEqualTo("")
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedSublabel() {
+ val presentationGetter = makeActivityInfoPresentationGetter(true, "", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ // With the substitute permission, empty sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testResolveInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() {
+ val presentationGetter = makeResolveInfoPresentationGetter(
+ false, "app_label", "activity_label", "resolve_info_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label")
+ }
+
+ @Test
+ fun testResolveInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() {
+ val presentationGetter = makeResolveInfoPresentationGetter(
+ false, "app_label", "activity_label", "app_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // Without the substitute permission, there's no logic to dedupe the labels.
+ // TODO: this matches our observations in the legacy code, but is it the right behavior? It
+ // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in
+ // the UI at least, but maybe that logic should be pulled back to the "presentation"?
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label")
+ }
+
+ @Test
+ fun testResolveInfoLabels_noSubstitutePermission_emptyRequestedSublabel() {
+ val presentationGetter = makeResolveInfoPresentationGetter(
+ false, "app_label", "activity_label", "")
+ assertThat(presentationGetter.getLabel()).isEqualTo("app_label")
+ // Without the substitute permission, empty sublabels are passed through as-is.
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("")
+ }
+
+ @Test
+ fun testResolveInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() {
+ val presentationGetter = makeResolveInfoPresentationGetter(
+ true, "app_label", "activity_label", "resolve_info_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label")
+ }
+
+ @Test
+ fun testResolveInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() {
+ val presentationGetter = makeResolveInfoPresentationGetter(
+ true, "app_label", "activity_label", "activity_label")
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ // With the substitute permission, duped sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedSublabel() {
+ val presentationGetter = makeResolveInfoPresentationGetter(
+ true, "app_label", "activity_label", "")
+ assertThat(presentationGetter.getLabel()).isEqualTo("activity_label")
+ // With the substitute permission, empty sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+
+ @Test
+ fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedLabelAndSublabel() {
+ val presentationGetter = makeResolveInfoPresentationGetter(
+ true, "app_label", "", "")
+ assertThat(presentationGetter.getLabel()).isEqualTo("")
+ // With the substitute permission, empty sublabels get converted to nulls.
+ assertThat(presentationGetter.getSubLabel()).isNull()
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/java/tests/src/com/android/intentresolver/TestApplication.kt
new file mode 100644
index 0000000..849cfba
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/TestApplication.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.app.Application
+import android.content.Context
+import android.os.UserHandle
+
+class TestApplication : Application() {
+
+ // return the current context as a work profile doesn't really exist in these tests
+ override fun createContextAsUser(user: UserHandle, flags: Int): Context = this
+}
\ No newline at end of file
diff --git a/java/tests/src/com/android/intentresolver/TestHelpers.kt b/java/tests/src/com/android/intentresolver/TestHelpers.kt
new file mode 100644
index 0000000..5b583fe
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/TestHelpers.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.app.prediction.AppTarget
+import android.app.prediction.AppTargetId
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+import android.content.pm.ShortcutManager.ShareShortcutInfo
+import android.os.Bundle
+import android.service.chooser.ChooserTarget
+import org.mockito.Mockito.`when` as whenever
+
+internal fun createShareShortcutInfo(
+ id: String,
+ componentName: ComponentName,
+ rank: Int
+): ShareShortcutInfo =
+ ShareShortcutInfo(
+ createShortcutInfo(id, componentName, rank),
+ componentName
+ )
+
+internal fun createShortcutInfo(
+ id: String,
+ componentName: ComponentName,
+ rank: Int
+): ShortcutInfo {
+ val context = mock<Context>()
+ whenever(context.packageName).thenReturn(componentName.packageName)
+ return ShortcutInfo.Builder(context, id)
+ .setShortLabel("Short Label $id")
+ .setLongLabel("Long Label $id")
+ .setActivity(componentName)
+ .setRank(rank)
+ .build()
+}
+
+internal fun createAppTarget(shortcutInfo: ShortcutInfo) =
+ AppTarget(
+ AppTargetId(shortcutInfo.id),
+ shortcutInfo,
+ shortcutInfo.activity?.className ?: error("missing activity info")
+ )
+
+fun createChooserTarget(
+ title: String, score: Float, componentName: ComponentName, shortcutId: String
+): ChooserTarget =
+ ChooserTarget(
+ title,
+ null,
+ score,
+ componentName,
+ Bundle().apply { putString(Intent.EXTRA_SHORTCUT_ID, shortcutId) }
+ )
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
index b901fc1..af2557e 100644
--- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
+++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
@@ -38,21 +38,18 @@
import static com.google.common.truth.Truth.assertThat;
-import static junit.framework.Assert.assertFalse;
import static junit.framework.Assert.assertNull;
-import static junit.framework.Assert.assertTrue;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
-import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.greaterThan;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -78,26 +75,29 @@
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.drawable.Icon;
-import android.metrics.LogMaker;
import android.net.Uri;
+import android.os.Bundle;
import android.os.UserHandle;
import android.provider.DeviceConfig;
import android.service.chooser.ChooserTarget;
+import android.util.HashedStringCache;
+import android.util.Pair;
+import android.util.SparseArray;
import android.view.View;
import androidx.annotation.CallSuper;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
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.shortcuts.ShortcutLoader;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
-import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
-import com.android.internal.util.FrameworkStatsLog;
-import com.android.internal.widget.GridLayoutManager;
-import com.android.internal.widget.RecyclerView;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
@@ -117,6 +117,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.Consumer;
import java.util.function.Function;
/**
@@ -130,7 +131,6 @@
* TODO: this can simply be renamed to "ChooserActivityTest" if that's ever unambiguous (i.e., if
* there's no risk of confusion with the framework tests that currently share the same name).
*/
-@Ignore("investigate b/241944046 and re-enabled")
@RunWith(Parameterized.class)
public class UnbundledChooserActivityTest {
@@ -252,13 +252,31 @@
mTestNum = testNum;
}
+ private void setDeviceConfigProperty(
+ @NonNull String propertyName,
+ @NonNull String value) {
+ // TODO: consider running with {@link #runWithShellPermissionIdentity()} to more narrowly
+ // request WRITE_DEVICE_CONFIG permissions if we get rid of the broad grant we currently
+ // configure in {@link #setup()}.
+ // TODO: is it really appropriate that this is always set with makeDefault=true?
+ boolean valueWasSet = DeviceConfig.setProperty(
+ DeviceConfig.NAMESPACE_SYSTEMUI,
+ propertyName,
+ value,
+ true /* makeDefault */);
+ if (!valueWasSet) {
+ throw new IllegalStateException(
+ "Could not set " + propertyName + " to " + value);
+ }
+ }
+
public void cleanOverrideData() {
ChooserActivityOverrideData.getInstance().reset();
ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride;
- DeviceConfig.setProperty(DeviceConfig.NAMESPACE_SYSTEMUI,
+
+ setDeviceConfigProperty(
SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
- Boolean.toString(true),
- true /* makeDefault*/);
+ Boolean.toString(true));
}
@Test
@@ -282,7 +300,7 @@
waitForIdle();
assertThat(activity.getAdapter().getCount(), is(2));
assertThat(activity.getAdapter().getServiceTargetCount(), is(0));
- onView(withIdFromRuntimeResource("title")).check(matches(withText("chooser test")));
+ onView(withId(android.R.id.title)).check(matches(withText("chooser test")));
}
@Test
@@ -302,8 +320,8 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test"));
waitForIdle();
- onView(withIdFromRuntimeResource("title"))
- .check(matches(withTextFromRuntimeResource("whichSendApplication")));
+ onView(withId(android.R.id.title))
+ .check(matches(withText(com.android.internal.R.string.whichSendApplication)));
}
@Test
@@ -323,8 +341,8 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("title"))
- .check(matches(withTextFromRuntimeResource("whichSendApplication")));
+ onView(withId(android.R.id.title))
+ .check(matches(withText(com.android.internal.R.string.whichSendApplication)));
}
@Test
@@ -344,9 +362,9 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_title"))
+ onView(withId(com.android.internal.R.id.content_preview_title))
.check(matches(not(isDisplayed())));
- onView(withIdFromRuntimeResource("content_preview_thumbnail"))
+ onView(withId(com.android.internal.R.id.content_preview_thumbnail))
.check(matches(not(isDisplayed())));
}
@@ -368,11 +386,11 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_title"))
+ onView(withId(com.android.internal.R.id.content_preview_title))
.check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_title"))
+ onView(withId(com.android.internal.R.id.content_preview_title))
.check(matches(withText(previewTitle)));
- onView(withIdFromRuntimeResource("content_preview_thumbnail"))
+ onView(withId(com.android.internal.R.id.content_preview_thumbnail))
.check(matches(not(isDisplayed())));
}
@@ -395,8 +413,9 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_title")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_thumbnail"))
+ onView(withId(com.android.internal.R.id.content_preview_title))
+ .check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.content_preview_thumbnail))
.check(matches(not(isDisplayed())));
}
@@ -405,7 +424,7 @@
String previewTitle = "My Content Preview Title";
Intent sendIntent = createSendTextIntentWithPreview(previewTitle,
Uri.parse("android.resource://com.android.frameworks.coretests/"
- + com.android.frameworks.coretests.R.drawable.test320x240));
+ + R.drawable.test320x240));
ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap();
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -421,8 +440,9 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_title")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_thumbnail"))
+ onView(withId(com.android.internal.R.id.content_preview_title))
+ .check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.content_preview_thumbnail))
.check(matches(isDisplayed()));
}
@@ -447,7 +467,7 @@
waitForIdle();
assertThat(activity.getAdapter().getCount(), is(2));
- onView(withIdFromRuntimeResource("profile_button")).check(doesNotExist());
+ onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist());
ResolveInfo[] chosen = new ResolveInfo[1];
ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> {
@@ -580,8 +600,8 @@
waitForIdle();
assertThat(activity.isFinishing(), is(false));
- onView(withIdFromRuntimeResource("empty")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("profile_pager")).check(matches(not(isDisplayed())));
+ onView(withId(android.R.id.empty)).check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.profile_pager)).check(matches(not(isDisplayed())));
InstrumentationRegistry.getInstrumentation().runOnMainSync(
() -> wrapper.getAdapter().handlePackagesChanged()
);
@@ -619,9 +639,7 @@
}
@Test @Ignore
- public void hasOtherProfileOneOption() throws Exception {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
+ public void hasOtherProfileOneOption() {
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4);
@@ -647,7 +665,6 @@
List<ResolvedComponentInfo> stableCopy =
createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10);
waitForIdle();
- Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs);
onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)))
.perform(click());
@@ -657,9 +674,6 @@
@Test @Ignore
public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
-
Intent sendIntent = createSendTextIntent();
List<ResolvedComponentInfo> resolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3);
@@ -697,9 +711,6 @@
@Test @Ignore
public void hasLastChosenActivityAndOtherProfile() throws Exception {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
-
Intent sendIntent = createSendTextIntent();
List<ResolvedComponentInfo> resolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3);
@@ -748,8 +759,8 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("chooser_copy_button")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("chooser_copy_button")).perform(click());
+ onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click());
ClipboardManager clipboard = (ClipboardManager) activity.getSystemService(
Context.CLIPBOARD_SERVICE);
ClipData clipData = clipboard.getPrimaryClip();
@@ -762,7 +773,7 @@
}
@Test
- public void copyTextToClipboardLogging() throws Exception {
+ public void copyTextToClipboardLogging() {
Intent sendIntent = createSendTextIntent();
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -772,24 +783,17 @@
Mockito.anyBoolean(),
Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
- MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger;
- ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+ final IChooserWrapper activity = (IChooserWrapper)
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("chooser_copy_button")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("chooser_copy_button")).perform(click());
+ onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click());
- verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture());
-
- // The last captured event is the selection of the target.
- assertThat(logMakerCaptor.getValue().getCategory(),
- is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET));
- assertThat(logMakerCaptor.getValue().getSubtype(), is(1));
+ ChooserActivityLogger logger = activity.getChooserActivityLogger();
+ verify(logger, times(1)).logActionSelected(eq(ChooserActivityLogger.SELECTION_TYPE_COPY));
}
-
@Test
@Ignore
public void testNearbyShareLogging() throws Exception {
@@ -806,52 +810,11 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("chooser_nearby_button")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("chooser_nearby_button")).perform(click());
-
- ChooserActivityLoggerFake logger =
- (ChooserActivityLoggerFake) activity.getChooserActivityLogger();
+ onView(withId(com.android.internal.R.id.chooser_nearby_button))
+ .check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.chooser_nearby_button)).perform(click());
// TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- logger.removeCallsForUiEventsOfType(
- ChooserActivityLogger.SharesheetStandardEvent
- .SHARESHEET_DIRECT_LOAD_COMPLETE.getId());
-
- // SHARESHEET_TRIGGERED:
- assertThat(logger.event(0).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId()));
-
- // SHARESHEET_STARTED:
- assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED));
- assertThat(logger.get(1).intent, is(Intent.ACTION_SEND));
- assertThat(logger.get(1).mimeType, is("text/plain"));
- assertThat(logger.get(1).packageName, is(
- InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName()));
- assertThat(logger.get(1).appProvidedApp, is(0));
- assertThat(logger.get(1).appProvidedDirect, is(0));
- assertThat(logger.get(1).isWorkprofile, is(false));
- assertThat(logger.get(1).previewType, is(3));
-
- // SHARESHEET_APP_LOAD_COMPLETE:
- assertThat(logger.event(2).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId()));
-
- // Next are just artifacts of test set-up:
- assertThat(logger.event(3).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId()));
- assertThat(logger.event(4).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId()));
-
- // SHARESHEET_NEARBY_TARGET_SELECTED:
- assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED));
- assertThat(logger.get(5).targetType,
- is(ChooserActivityLogger
- .SharesheetTargetSelectedEvent.SHARESHEET_NEARBY_TARGET_SELECTED.getId()));
-
- // No more events.
- assertThat(logger.numCalls(), is(6));
}
@@ -860,7 +823,7 @@
public void testEditImageLogs() throws Exception {
Intent sendIntent = createSendImageIntent(
Uri.parse("android.resource://com.android.frameworks.coretests/"
- + com.android.frameworks.coretests.R.drawable.test320x240));
+ + R.drawable.test320x240));
ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap();
ChooserActivityOverrideData.getInstance().isImageType = true;
@@ -877,59 +840,17 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("chooser_edit_button")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("chooser_edit_button")).perform(click());
-
- ChooserActivityLoggerFake logger =
- (ChooserActivityLoggerFake) activity.getChooserActivityLogger();
+ onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.chooser_edit_button)).perform(click());
// TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- logger.removeCallsForUiEventsOfType(
- ChooserActivityLogger.SharesheetStandardEvent
- .SHARESHEET_DIRECT_LOAD_COMPLETE.getId());
-
- // SHARESHEET_TRIGGERED:
- assertThat(logger.event(0).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId()));
-
- // SHARESHEET_STARTED:
- assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED));
- assertThat(logger.get(1).intent, is(Intent.ACTION_SEND));
- assertThat(logger.get(1).mimeType, is("image/png"));
- assertThat(logger.get(1).packageName, is(
- InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName()));
- assertThat(logger.get(1).appProvidedApp, is(0));
- assertThat(logger.get(1).appProvidedDirect, is(0));
- assertThat(logger.get(1).isWorkprofile, is(false));
- assertThat(logger.get(1).previewType, is(1));
-
- // SHARESHEET_APP_LOAD_COMPLETE:
- assertThat(logger.event(2).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId()));
-
- // Next are just artifacts of test set-up:
- assertThat(logger.event(3).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId()));
- assertThat(logger.event(4).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId()));
-
- // SHARESHEET_EDIT_TARGET_SELECTED:
- assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED));
- assertThat(logger.get(5).targetType,
- is(ChooserActivityLogger
- .SharesheetTargetSelectedEvent.SHARESHEET_EDIT_TARGET_SELECTED.getId()));
-
- // No more events.
- assertThat(logger.numCalls(), is(6));
}
@Test
public void oneVisibleImagePreview() throws InterruptedException {
Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/"
- + com.android.frameworks.coretests.R.drawable.test320x240);
+ + R.drawable.test320x240);
ArrayList<Uri> uris = new ArrayList<>();
uris.add(uri);
@@ -952,20 +873,20 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_image_1_large"))
+ onView(withId(com.android.internal.R.id.content_preview_image_1_large))
.check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_image_2_large"))
+ onView(withId(com.android.internal.R.id.content_preview_image_2_large))
.check(matches(not(isDisplayed())));
- onView(withIdFromRuntimeResource("content_preview_image_2_small"))
+ onView(withId(com.android.internal.R.id.content_preview_image_2_small))
.check(matches(not(isDisplayed())));
- onView(withIdFromRuntimeResource("content_preview_image_3_small"))
+ onView(withId(com.android.internal.R.id.content_preview_image_3_small))
.check(matches(not(isDisplayed())));
}
@Test
public void twoVisibleImagePreview() throws InterruptedException {
Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/"
- + com.android.frameworks.coretests.R.drawable.test320x240);
+ + R.drawable.test320x240);
ArrayList<Uri> uris = new ArrayList<>();
uris.add(uri);
@@ -989,20 +910,20 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_image_1_large"))
+ onView(withId(com.android.internal.R.id.content_preview_image_1_large))
.check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_image_2_large"))
+ onView(withId(com.android.internal.R.id.content_preview_image_2_large))
.check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_image_2_small"))
+ onView(withId(com.android.internal.R.id.content_preview_image_2_small))
.check(matches(not(isDisplayed())));
- onView(withIdFromRuntimeResource("content_preview_image_3_small"))
+ onView(withId(com.android.internal.R.id.content_preview_image_3_small))
.check(matches(not(isDisplayed())));
}
@Test
public void threeOrMoreVisibleImagePreview() throws InterruptedException {
Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/"
- + com.android.frameworks.coretests.R.drawable.test320x240);
+ + R.drawable.test320x240);
ArrayList<Uri> uris = new ArrayList<>();
uris.add(uri);
@@ -1029,13 +950,13 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_image_1_large"))
+ onView(withId(com.android.internal.R.id.content_preview_image_1_large))
.check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_image_2_large"))
+ onView(withId(com.android.internal.R.id.content_preview_image_2_large))
.check(matches(not(isDisplayed())));
- onView(withIdFromRuntimeResource("content_preview_image_2_small"))
+ onView(withId(com.android.internal.R.id.content_preview_image_2_small))
.check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_image_3_small"))
+ onView(withId(com.android.internal.R.id.content_preview_image_3_small))
.check(matches(isDisplayed()));
}
@@ -1044,25 +965,12 @@
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
- MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger;
- ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test"));
+ final IChooserWrapper activity = (IChooserWrapper)
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test"));
+ ChooserActivityLogger logger = activity.getChooserActivityLogger();
waitForIdle();
- verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture());
- assertThat(logMakerCaptor.getAllValues().get(0).getCategory(),
- is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN));
- assertThat(logMakerCaptor
- .getAllValues().get(0)
- .getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS),
- is(notNullValue()));
- assertThat(logMakerCaptor
- .getAllValues().get(0)
- .getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE),
- is(TEST_MIME_TYPE));
- assertThat(logMakerCaptor
- .getAllValues().get(0)
- .getSubtype(),
- is(MetricsEvent.PARENT_PROFILE));
+
+ verify(logger).logChooserActivityShown(eq(false), eq(TEST_MIME_TYPE), anyLong());
}
@Test
@@ -1071,49 +979,32 @@
sendIntent.setType(TEST_MIME_TYPE);
ChooserActivityOverrideData.getInstance().alternateProfileSetting =
MetricsEvent.MANAGED_PROFILE;
- MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger;
- ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test"));
+
+ final IChooserWrapper activity = (IChooserWrapper)
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test"));
+ ChooserActivityLogger logger = activity.getChooserActivityLogger();
waitForIdle();
- verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture());
- assertThat(logMakerCaptor.getAllValues().get(0).getCategory(),
- is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN));
- assertThat(logMakerCaptor
- .getAllValues().get(0)
- .getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS),
- is(notNullValue()));
- assertThat(logMakerCaptor
- .getAllValues().get(0)
- .getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE),
- is(TEST_MIME_TYPE));
- assertThat(logMakerCaptor
- .getAllValues().get(0)
- .getSubtype(),
- is(MetricsEvent.MANAGED_PROFILE));
+
+ verify(logger).logChooserActivityShown(eq(true), eq(TEST_MIME_TYPE), anyLong());
}
@Test
public void testEmptyPreviewLogging() {
Intent sendIntent = createSendTextIntentWithPreview(null, null);
- MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger;
- ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "empty preview logger test"));
+ final IChooserWrapper activity = (IChooserWrapper)
+ mActivityRule.launchActivity(
+ Intent.createChooser(sendIntent, "empty preview logger test"));
+ ChooserActivityLogger logger = activity.getChooserActivityLogger();
waitForIdle();
- verify(mockLogger, Mockito.times(1)).write(logMakerCaptor.capture());
- // First invocation is from onCreate
- assertThat(logMakerCaptor.getAllValues().get(0).getCategory(),
- is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN));
+ verify(logger).logChooserActivityShown(eq(false), eq(null), anyLong());
}
@Test
public void testTitlePreviewLogging() {
Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null);
- MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger;
- ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class);
-
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent(
@@ -1122,20 +1013,19 @@
Mockito.anyBoolean(),
Mockito.isA(List.class))).thenReturn(resolvedComponentInfos);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+ final IChooserWrapper activity = (IChooserWrapper)
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
+
// Second invocation is from onCreate
- verify(mockLogger, Mockito.times(2)).write(logMakerCaptor.capture());
- assertThat(logMakerCaptor.getAllValues().get(0).getSubtype(),
- is(CONTENT_PREVIEW_TEXT));
- assertThat(logMakerCaptor.getAllValues().get(0).getCategory(),
- is(MetricsEvent.ACTION_SHARE_WITH_PREVIEW));
+ ChooserActivityLogger logger = activity.getChooserActivityLogger();
+ Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_TEXT));
}
@Test
public void testImagePreviewLogging() {
Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/"
- + com.android.frameworks.coretests.R.drawable.test320x240);
+ + R.drawable.test320x240);
ArrayList<Uri> uris = new ArrayList<>();
uris.add(uri);
@@ -1157,16 +1047,11 @@
Mockito.isA(List.class)))
.thenReturn(resolvedComponentInfos);
- MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger;
- ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class);
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+ final IChooserWrapper activity = (IChooserWrapper)
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- verify(mockLogger, Mockito.times(2)).write(logMakerCaptor.capture());
- // First invocation is from onCreate
- assertThat(logMakerCaptor.getAllValues().get(0).getSubtype(),
- is(CONTENT_PREVIEW_IMAGE));
- assertThat(logMakerCaptor.getAllValues().get(0).getCategory(),
- is(MetricsEvent.ACTION_SHARE_WITH_PREVIEW));
+ ChooserActivityLogger logger = activity.getChooserActivityLogger();
+ Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_IMAGE));
}
@Test
@@ -1192,10 +1077,11 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_filename")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_filename"))
+ onView(withId(com.android.internal.R.id.content_preview_filename))
+ .check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.content_preview_filename))
.check(matches(withText("app.pdf")));
- onView(withIdFromRuntimeResource("content_preview_file_icon"))
+ onView(withId(com.android.internal.R.id.content_preview_file_icon))
.check(matches(isDisplayed()));
}
@@ -1225,11 +1111,11 @@
.thenReturn(resolvedComponentInfos);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_filename"))
+ onView(withId(com.android.internal.R.id.content_preview_filename))
.check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_filename"))
+ onView(withId(com.android.internal.R.id.content_preview_filename))
.check(matches(withText("app.pdf + 2 files")));
- onView(withIdFromRuntimeResource("content_preview_file_icon"))
+ onView(withId(com.android.internal.R.id.content_preview_file_icon))
.check(matches(isDisplayed()));
}
@@ -1258,10 +1144,11 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_filename")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_filename"))
+ onView(withId(com.android.internal.R.id.content_preview_filename))
+ .check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.content_preview_filename))
.check(matches(withText("app.pdf")));
- onView(withIdFromRuntimeResource("content_preview_file_icon"))
+ onView(withId(com.android.internal.R.id.content_preview_file_icon))
.check(matches(isDisplayed()));
}
@@ -1297,10 +1184,11 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("content_preview_filename")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("content_preview_filename"))
+ onView(withId(com.android.internal.R.id.content_preview_filename))
+ .check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.content_preview_filename))
.check(matches(withText("app.pdf + 1 file")));
- onView(withIdFromRuntimeResource("content_preview_file_icon"))
+ onView(withId(com.android.internal.R.id.content_preview_file_icon))
.check(matches(isDisplayed()));
}
@@ -1347,95 +1235,9 @@
is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST));
}
- @Test
- public void testConvertToChooserTarget_predictionService() {
- 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);
-
- final ChooserActivity activity =
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- List<ShareShortcutInfo> shortcuts = createShortcuts(activity);
-
- int[] expectedOrderAllShortcuts = {0, 1, 2, 3};
- float[] expectedScoreAllShortcuts = {1.0f, 0.99f, 0.98f, 0.97f};
-
- List<ChooserTarget> chooserTargets = activity.convertToChooserTarget(shortcuts, shortcuts,
- null, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE);
- assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets,
- expectedOrderAllShortcuts, expectedScoreAllShortcuts);
-
- List<ShareShortcutInfo> subset = new ArrayList<>();
- subset.add(shortcuts.get(1));
- subset.add(shortcuts.get(2));
- subset.add(shortcuts.get(3));
-
- int[] expectedOrderSubset = {1, 2, 3};
- float[] expectedScoreSubset = {0.99f, 0.98f, 0.97f};
-
- chooserTargets = activity.convertToChooserTarget(subset, shortcuts, null,
- TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE);
- assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets,
- expectedOrderSubset, expectedScoreSubset);
- }
-
- @Test
- public void testConvertToChooserTarget_shortcutManager() {
- 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);
-
- final ChooserActivity activity =
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- waitForIdle();
-
- List<ShareShortcutInfo> shortcuts = createShortcuts(activity);
-
- int[] expectedOrderAllShortcuts = {2, 0, 3, 1};
- float[] expectedScoreAllShortcuts = {1.0f, 0.99f, 0.99f, 0.98f};
-
- List<ChooserTarget> chooserTargets = activity.convertToChooserTarget(shortcuts, shortcuts,
- null, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER);
- assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets,
- expectedOrderAllShortcuts, expectedScoreAllShortcuts);
-
- List<ShareShortcutInfo> subset = new ArrayList<>();
- subset.add(shortcuts.get(1));
- subset.add(shortcuts.get(2));
- subset.add(shortcuts.get(3));
-
- int[] expectedOrderSubset = {2, 3, 1};
- float[] expectedScoreSubset = {1.0f, 0.99f, 0.98f};
-
- chooserTargets = activity.convertToChooserTarget(subset, shortcuts, null,
- TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER);
- assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets,
- expectedOrderSubset, expectedScoreSubset);
- }
-
// This test is too long and too slow and should not be taken as an example for future tests.
- @Test @Ignore
- public void testDirectTargetSelectionLogging() throws InterruptedException {
+ @Test
+ public void testDirectTargetSelectionLogging() {
Intent sendIntent = createSendTextIntent();
// We need app targets for direct targets to get displayed
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1450,44 +1252,54 @@
Mockito.isA(List.class)))
.thenReturn(resolvedComponentInfos);
- // Set up resources
- MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger;
- ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class);
- // Create direct share target
+ // create test shortcut loader factory, remember loaders and their callbacks
+ SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
+ createShortcutLoaderFactory();
+
+ // 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
+ assertThat(
+ "Wrong number of app targets",
+ appTargets.getValue().length,
+ is(resolvedComponentInfos.size()));
List<ChooserTarget> serviceTargets = createDirectShareTargets(1, "");
- ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0);
-
- // Start activity
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
-
- // Insert the direct share target
- Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>();
- directShareToShortcutInfos.put(serviceTargets.get(0), null);
- InstrumentationRegistry.getInstrumentation().runOnMainSync(
- () -> activity.getAdapter().addServiceResults(
- activity.createTestDisplayResolveInfo(sendIntent,
- ri,
- "testLabel",
- "testInfo",
- sendIntent,
- /* resolveInfoPresentationGetter */ null),
- serviceTargets,
- TARGET_TYPE_CHOOSER_TARGET,
- directShareToShortcutInfos)
+ ShortcutLoader.Result result = new ShortcutLoader.Result(
+ true,
+ 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();
- // Thread.sleep shouldn't be a thing in an integration test but it's
- // necessary here because of the way the code is structured
- // TODO: restructure the tests b/129870719
- Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs);
-
- assertThat("Chooser should have 3 targets (2 apps, 1 direct)",
- activity.getAdapter().getCount(), is(3));
- assertThat("Chooser should have exactly one selectable direct target",
- activity.getAdapter().getSelectableServiceTargetCount(), is(1));
- assertThat("The resolver info must match the resolver info used to create the target",
- activity.getAdapter().getItem(0).getResolveInfo(), is(ri));
+ final ChooserListAdapter activeAdapter = activity.getAdapter();
+ assertThat(
+ "Chooser should have 3 targets (2 apps, 1 direct)",
+ activeAdapter.getCount(),
+ is(3));
+ assertThat(
+ "Chooser should have exactly one selectable direct target",
+ activeAdapter.getSelectableServiceTargetCount(),
+ is(1));
+ assertThat(
+ "The resolver info must match the resolver info used to create the target",
+ activeAdapter.getItem(0).getResolveInfo(),
+ is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
// Click on the direct target
String name = serviceTargets.get(0).getTitle().toString();
@@ -1495,24 +1307,27 @@
.perform(click());
waitForIdle();
- // Currently we're seeing 3 invocations
- // 1. ChooserActivity.onCreate()
- // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView()
- // 3. ChooserActivity.startSelected -- which is the one we're after
- verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture());
- assertThat(logMakerCaptor.getAllValues().get(2).getCategory(),
- is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET));
- String hashedName = (String) logMakerCaptor
- .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_HASHED_TARGET_NAME);
- assertThat("Hash is not predictable but must be obfuscated",
+ ArgumentCaptor<HashedStringCache.HashResult> hashCaptor =
+ ArgumentCaptor.forClass(HashedStringCache.HashResult.class);
+ verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected(
+ eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE),
+ /* packageName= */ any(),
+ /* positionPicked= */ anyInt(),
+ /* directTargetAlsoRanked= */ eq(-1),
+ /* numCallerProvided= */ anyInt(),
+ /* directTargetHashed= */ hashCaptor.capture(),
+ /* isPinned= */ anyBoolean(),
+ /* successfullySelected= */ anyBoolean(),
+ /* selectionCost= */ anyLong());
+ String hashedName = hashCaptor.getValue().hashedString;
+ assertThat(
+ "Hash is not predictable but must be obfuscated",
hashedName, is(not(name)));
- assertThat("The packages shouldn't match for app target and direct target", logMakerCaptor
- .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(-1));
}
// This test is too long and too slow and should not be taken as an example for future tests.
- @Test @Ignore
- public void testDirectTargetLoggingWithRankedAppTarget() throws InterruptedException {
+ @Test
+ public void testDirectTargetLoggingWithRankedAppTarget() {
Intent sendIntent = createSendTextIntent();
// We need app targets for direct targets to get displayed
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -1527,44 +1342,56 @@
Mockito.isA(List.class)))
.thenReturn(resolvedComponentInfos);
- // Set up resources
- MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger;
- ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class);
- // Create direct share target
- List<ChooserTarget> serviceTargets = createDirectShareTargets(1,
- resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
- ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0);
+ // create test shortcut loader factory, remember loaders and their callbacks
+ SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
+ createShortcutLoaderFactory();
// Start activity
final IChooserWrapper activity = (IChooserWrapper)
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
+ waitForIdle();
- // Insert the direct share target
- Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>();
- directShareToShortcutInfos.put(serviceTargets.get(0), null);
- InstrumentationRegistry.getInstrumentation().runOnMainSync(
- () -> activity.getAdapter().addServiceResults(
- activity.createTestDisplayResolveInfo(sendIntent,
- ri,
- "testLabel",
- "testInfo",
- sendIntent,
- /* resolveInfoPresentationGetter */ null),
- serviceTargets,
- TARGET_TYPE_CHOOSER_TARGET,
- directShareToShortcutInfos)
+ // verify that ShortcutLoader was queried
+ ArgumentCaptor<DisplayResolveInfo[]> appTargets =
+ ArgumentCaptor.forClass(DisplayResolveInfo[].class);
+ verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture());
+
+ // send shortcuts
+ assertThat(
+ "Wrong number of app targets",
+ appTargets.getValue().length,
+ is(resolvedComponentInfos.size()));
+ List<ChooserTarget> serviceTargets = createDirectShareTargets(
+ 1,
+ resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
+ ShortcutLoader.Result result = new ShortcutLoader.Result(
+ true,
+ appTargets.getValue(),
+ new ShortcutLoader.ShortcutResultInfo[] {
+ new ShortcutLoader.ShortcutResultInfo(
+ appTargets.getValue()[0],
+ serviceTargets
+ )
+ },
+ new HashMap<>(),
+ new HashMap<>()
);
- // Thread.sleep shouldn't be a thing in an integration test but it's
- // necessary here because of the way the code is structured
- // TODO: restructure the tests b/129870719
- Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs);
+ activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
+ waitForIdle();
- assertThat("Chooser should have 3 targets (2 apps, 1 direct)",
- activity.getAdapter().getCount(), is(3));
- assertThat("Chooser should have exactly one selectable direct target",
- activity.getAdapter().getSelectableServiceTargetCount(), is(1));
- assertThat("The resolver info must match the resolver info used to create the target",
- activity.getAdapter().getItem(0).getResolveInfo(), is(ri));
+ final ChooserListAdapter activeAdapter = activity.getAdapter();
+ assertThat(
+ "Chooser should have 3 targets (2 apps, 1 direct)",
+ activeAdapter.getCount(),
+ is(3));
+ assertThat(
+ "Chooser should have exactly one selectable direct target",
+ activeAdapter.getSelectableServiceTargetCount(),
+ is(1));
+ assertThat(
+ "The resolver info must match the resolver info used to create the target",
+ activeAdapter.getItem(0).getResolveInfo(),
+ is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
// Click on the direct target
String name = serviceTargets.get(0).getTitle().toString();
@@ -1572,19 +1399,20 @@
.perform(click());
waitForIdle();
- // Currently we're seeing 3 invocations
- // 1. ChooserActivity.onCreate()
- // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView()
- // 3. ChooserActivity.startSelected -- which is the one we're after
- verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture());
- assertThat(logMakerCaptor.getAllValues().get(2).getCategory(),
- is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET));
- assertThat("The packages should match for app target and direct target", logMakerCaptor
- .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0));
+ verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected(
+ eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE),
+ /* packageName= */ any(),
+ /* positionPicked= */ anyInt(),
+ /* directTargetAlsoRanked= */ eq(0),
+ /* numCallerProvided= */ anyInt(),
+ /* directTargetHashed= */ any(),
+ /* isPinned= */ anyBoolean(),
+ /* successfullySelected= */ anyBoolean(),
+ /* selectionCost= */ anyLong());
}
- @Test @Ignore
- public void testShortcutTargetWithApplyAppLimits() throws InterruptedException {
+ @Test
+ public void testShortcutTargetWithApplyAppLimits() {
// Set up resources
ChooserActivityOverrideData.getInstance().resources = Mockito.spy(
InstrumentationRegistry.getInstrumentation().getContext().getResources());
@@ -1592,8 +1420,7 @@
ChooserActivityOverrideData
.getInstance()
.resources
- .getInteger(
- getRuntimeResourceId("config_maxShortcutTargetsPerApp", "integer")))
+ .getInteger(R.integer.config_maxShortcutTargetsPerApp))
.thenReturn(1);
Intent sendIntent = createSendTextIntent();
// We need app targets for direct targets to get displayed
@@ -1608,56 +1435,68 @@
Mockito.anyBoolean(),
Mockito.isA(List.class)))
.thenReturn(resolvedComponentInfos);
- // Create direct share target
- List<ChooserTarget> serviceTargets = createDirectShareTargets(2,
- resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
- ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0);
+
+ // create test shortcut loader factory, remember loaders and their callbacks
+ SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
+ createShortcutLoaderFactory();
// Start activity
- final ChooserActivity activity =
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- final IChooserWrapper wrapper = (IChooserWrapper) activity;
+ final IChooserWrapper activity = (IChooserWrapper) mActivityRule
+ .launchActivity(Intent.createChooser(sendIntent, null));
+ waitForIdle();
- // Insert the direct share target
- Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>();
- List<ShareShortcutInfo> shortcutInfos = createShortcuts(activity);
- directShareToShortcutInfos.put(serviceTargets.get(0),
- shortcutInfos.get(0).getShortcutInfo());
- directShareToShortcutInfos.put(serviceTargets.get(1),
- shortcutInfos.get(1).getShortcutInfo());
- InstrumentationRegistry.getInstrumentation().runOnMainSync(
- () -> wrapper.getAdapter().addServiceResults(
- wrapper.createTestDisplayResolveInfo(sendIntent,
- ri,
- "testLabel",
- "testInfo",
- sendIntent,
- /* resolveInfoPresentationGetter */ null),
- serviceTargets,
- TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE,
- directShareToShortcutInfos)
+ // verify that ShortcutLoader was queried
+ ArgumentCaptor<DisplayResolveInfo[]> appTargets =
+ ArgumentCaptor.forClass(DisplayResolveInfo[].class);
+ verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture());
+
+ // send shortcuts
+ assertThat(
+ "Wrong number of app targets",
+ appTargets.getValue().length,
+ is(resolvedComponentInfos.size()));
+ List<ChooserTarget> serviceTargets = createDirectShareTargets(
+ 2,
+ resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
+ ShortcutLoader.Result result = new ShortcutLoader.Result(
+ true,
+ appTargets.getValue(),
+ new ShortcutLoader.ShortcutResultInfo[] {
+ new ShortcutLoader.ShortcutResultInfo(
+ appTargets.getValue()[0],
+ serviceTargets
+ )
+ },
+ new HashMap<>(),
+ new HashMap<>()
);
- // Thread.sleep shouldn't be a thing in an integration test but it's
- // necessary here because of the way the code is structured
- // TODO: restructure the tests b/129870719
- Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs);
+ activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
+ waitForIdle();
- assertThat("Chooser should have 3 targets (2 apps, 1 direct)",
- wrapper.getAdapter().getCount(), is(3));
- assertThat("Chooser should have exactly one selectable direct target",
- wrapper.getAdapter().getSelectableServiceTargetCount(), is(1));
- assertThat("The resolver info must match the resolver info used to create the target",
- wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri));
- assertThat("The display label must match",
- wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0"));
+ final ChooserListAdapter activeAdapter = activity.getAdapter();
+ assertThat(
+ "Chooser should have 3 targets (2 apps, 1 direct)",
+ activeAdapter.getCount(),
+ is(3));
+ assertThat(
+ "Chooser should have exactly one selectable direct target",
+ activeAdapter.getSelectableServiceTargetCount(),
+ is(1));
+ assertThat(
+ "The resolver info must match the resolver info used to create the target",
+ activeAdapter.getItem(0).getResolveInfo(),
+ is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
+ assertThat(
+ "The display label must match",
+ activeAdapter.getItem(0).getDisplayLabel(),
+ is("testTitle0"));
}
- @Test @Ignore
- public void testShortcutTargetWithoutApplyAppLimits() throws InterruptedException {
- DeviceConfig.setProperty(DeviceConfig.NAMESPACE_SYSTEMUI,
+ @Test
+ public void testShortcutTargetWithoutApplyAppLimits() {
+ setDeviceConfigProperty(
SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
- Boolean.toString(false),
- true /* makeDefault*/);
+ Boolean.toString(false));
// Set up resources
ChooserActivityOverrideData.getInstance().resources = Mockito.spy(
InstrumentationRegistry.getInstrumentation().getContext().getResources());
@@ -1665,8 +1504,7 @@
ChooserActivityOverrideData
.getInstance()
.resources
- .getInteger(
- getRuntimeResourceId("config_maxShortcutTargetsPerApp", "integer")))
+ .getInteger(R.integer.config_maxShortcutTargetsPerApp))
.thenReturn(1);
Intent sendIntent = createSendTextIntent();
// We need app targets for direct targets to get displayed
@@ -1681,50 +1519,149 @@
Mockito.anyBoolean(),
Mockito.isA(List.class)))
.thenReturn(resolvedComponentInfos);
- // Create direct share target
- List<ChooserTarget> serviceTargets = createDirectShareTargets(2,
- resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
- ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0);
+
+ // create test shortcut loader factory, remember loaders and their callbacks
+ SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
+ createShortcutLoaderFactory();
// Start activity
- final ChooserActivity activity =
+ final IChooserWrapper activity = (IChooserWrapper)
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- final IChooserWrapper wrapper = (IChooserWrapper) activity;
+ waitForIdle();
- // Insert the direct share target
- Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>();
- List<ShareShortcutInfo> shortcutInfos = createShortcuts(activity);
- directShareToShortcutInfos.put(serviceTargets.get(0),
- shortcutInfos.get(0).getShortcutInfo());
- directShareToShortcutInfos.put(serviceTargets.get(1),
- shortcutInfos.get(1).getShortcutInfo());
- InstrumentationRegistry.getInstrumentation().runOnMainSync(
- () -> wrapper.getAdapter().addServiceResults(
- wrapper.createTestDisplayResolveInfo(sendIntent,
- ri,
- "testLabel",
- "testInfo",
- sendIntent,
- /* resolveInfoPresentationGetter */ null),
- serviceTargets,
- TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE,
- directShareToShortcutInfos)
+ // verify that ShortcutLoader was queried
+ ArgumentCaptor<DisplayResolveInfo[]> appTargets =
+ ArgumentCaptor.forClass(DisplayResolveInfo[].class);
+ verify(shortcutLoaders.get(0).first, times(1)).queryShortcuts(appTargets.capture());
+
+ // send shortcuts
+ assertThat(
+ "Wrong number of app targets",
+ appTargets.getValue().length,
+ is(resolvedComponentInfos.size()));
+ List<ChooserTarget> serviceTargets = createDirectShareTargets(
+ 2,
+ resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
+ ShortcutLoader.Result result = new ShortcutLoader.Result(
+ true,
+ appTargets.getValue(),
+ new ShortcutLoader.ShortcutResultInfo[] {
+ new ShortcutLoader.ShortcutResultInfo(
+ appTargets.getValue()[0],
+ serviceTargets
+ )
+ },
+ new HashMap<>(),
+ new HashMap<>()
);
- // Thread.sleep shouldn't be a thing in an integration test but it's
- // necessary here because of the way the code is structured
- // TODO: restructure the tests b/129870719
- Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs);
+ activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
+ waitForIdle();
- assertThat("Chooser should have 4 targets (2 apps, 2 direct)",
- wrapper.getAdapter().getCount(), is(4));
- assertThat("Chooser should have exactly two selectable direct target",
- wrapper.getAdapter().getSelectableServiceTargetCount(), is(2));
- assertThat("The resolver info must match the resolver info used to create the target",
- wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri));
- assertThat("The display label must match",
- wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0"));
- assertThat("The display label must match",
- wrapper.getAdapter().getItem(1).getDisplayLabel(), is("testTitle1"));
+ final ChooserListAdapter activeAdapter = activity.getAdapter();
+ assertThat(
+ "Chooser should have 4 targets (2 apps, 2 direct)",
+ activeAdapter.getCount(),
+ is(4));
+ assertThat(
+ "Chooser should have exactly two selectable direct target",
+ activeAdapter.getSelectableServiceTargetCount(),
+ is(2));
+ assertThat(
+ "The resolver info must match the resolver info used to create the target",
+ activeAdapter.getItem(0).getResolveInfo(),
+ is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
+ assertThat(
+ "The display label must match",
+ activeAdapter.getItem(0).getDisplayLabel(),
+ is("testTitle0"));
+ assertThat(
+ "The display label must match",
+ activeAdapter.getItem(1).getDisplayLabel(),
+ is("testTitle1"));
+ }
+
+ @Test
+ public void testLaunchWithCallerProvidedTarget() {
+ setDeviceConfigProperty(
+ SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI,
+ Boolean.toString(false));
+ // Set up resources
+ ChooserActivityOverrideData.getInstance().resources = Mockito.spy(
+ InstrumentationRegistry.getInstrumentation().getContext().getResources());
+ when(
+ ChooserActivityOverrideData
+ .getInstance()
+ .resources
+ .getInteger(R.integer.config_maxShortcutTargetsPerApp))
+ .thenReturn(1);
+
+ // 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);
+
+ // set caller-provided target
+ Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null);
+ String callerTargetLabel = "Caller Target";
+ ChooserTarget[] targets = new ChooserTarget[] {
+ new ChooserTarget(
+ callerTargetLabel,
+ Icon.createWithBitmap(createBitmap()),
+ 0.1f,
+ resolvedComponentInfos.get(0).name,
+ new Bundle())
+ };
+ chooserIntent.putExtra(Intent.EXTRA_CHOOSER_TARGETS, targets);
+
+ // create test shortcut loader factory, remember loaders and their callbacks
+ SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders =
+ createShortcutLoaderFactory();
+
+ // Start activity
+ final IChooserWrapper activity = (IChooserWrapper)
+ mActivityRule.launchActivity(chooserIntent);
+ 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
+ assertThat(
+ "Wrong number of app targets",
+ appTargets.getValue().length,
+ is(resolvedComponentInfos.size()));
+ ShortcutLoader.Result result = new ShortcutLoader.Result(
+ true,
+ appTargets.getValue(),
+ new ShortcutLoader.ShortcutResultInfo[0],
+ new HashMap<>(),
+ new HashMap<>());
+ activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
+ waitForIdle();
+
+ final ChooserListAdapter activeAdapter = activity.getAdapter();
+ assertThat(
+ "Chooser should have 3 targets (2 apps, 1 direct)",
+ activeAdapter.getCount(),
+ is(3));
+ assertThat(
+ "Chooser should have exactly two selectable direct target",
+ activeAdapter.getSelectableServiceTargetCount(),
+ is(1));
+ assertThat(
+ "The display label must match",
+ activeAdapter.getItem(0).getDisplayLabel(),
+ is(callerTargetLabel));
}
@Test
@@ -1742,7 +1679,7 @@
.getContext().getResources().getConfiguration()));
waitForIdle();
- onView(withIdFromRuntimeResource("resolver_list"))
+ onView(withId(com.android.internal.R.id.resolver_list))
.check(matches(withGridColumnCount(6)));
}
@@ -1760,8 +1697,7 @@
}
private void testDirectTargetLoggingWithAppTargetNotRanked(
- int orientation, int appTargetsExpected
- ) throws InterruptedException {
+ int orientation, int appTargetsExpected) {
Configuration configuration =
new Configuration(InstrumentationRegistry.getInstrumentation().getContext()
.getResources().getConfiguration());
@@ -1790,18 +1726,14 @@
Mockito.isA(List.class)))
.thenReturn(resolvedComponentInfos);
- // Set up resources
- MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger;
- ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class);
// Create direct share target
List<ChooserTarget> serviceTargets = createDirectShareTargets(1,
resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName);
ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0);
// Start activity
- final IChooserWrapper activity = (IChooserWrapper)
+ final IChooserWrapper wrapper = (IChooserWrapper)
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
- final IChooserWrapper wrapper = (IChooserWrapper) activity;
// Insert the direct share target
Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>();
directShareToShortcutInfos.put(serviceTargets.get(0), null);
@@ -1815,12 +1747,9 @@
/* resolveInfoPresentationGetter */ null),
serviceTargets,
TARGET_TYPE_CHOOSER_TARGET,
- directShareToShortcutInfos)
+ directShareToShortcutInfos,
+ /* directShareToAppTargets */ null)
);
- // Thread.sleep shouldn't be a thing in an integration test but it's
- // necessary here because of the way the code is structured
- // TODO: restructure the tests b/129870719
- Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs);
assertThat(
String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)",
@@ -1837,21 +1766,22 @@
.perform(click());
waitForIdle();
- // Currently we're seeing 3 invocations
- // 1. ChooserActivity.onCreate()
- // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView()
- // 3. ChooserActivity.startSelected -- which is the one we're after
- verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture());
- assertThat(logMakerCaptor.getAllValues().get(2).getCategory(),
- is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET));
- assertThat("The packages shouldn't match for app target and direct target", logMakerCaptor
- .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(-1));
+ ChooserActivityLogger logger = wrapper.getChooserActivityLogger();
+ verify(logger, times(1)).logShareTargetSelected(
+ eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE),
+ /* packageName= */ any(),
+ /* positionPicked= */ anyInt(),
+ // The packages sholdn't match for app target and direct target:
+ /* directTargetAlsoRanked= */ eq(-1),
+ /* numCallerProvided= */ anyInt(),
+ /* directTargetHashed= */ any(),
+ /* isPinned= */ anyBoolean(),
+ /* successfullySelected= */ anyBoolean(),
+ /* selectionCost= */ anyLong());
}
@Test
public void testWorkTab_displayedWhenWorkProfileUserAvailable() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
markWorkProfileUserAvailable();
@@ -1859,26 +1789,22 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withIdFromRuntimeResource("tabs")).check(matches(isDisplayed()));
+ onView(withId(android.R.id.tabs)).check(matches(isDisplayed()));
}
@Test
public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withIdFromRuntimeResource("tabs")).check(matches(not(isDisplayed())));
+ onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed())));
}
@Test
public void testWorkTab_eachTabUsesExpectedAdapter() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
int personalProfileTargets = 3;
int otherProfileTargets = 1;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
@@ -1897,7 +1823,7 @@
waitForIdle();
assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0));
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10));
assertThat(activity.getPersonalListAdapter().getCount(), is(personalProfileTargets));
assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets));
@@ -1905,8 +1831,6 @@
@Test
public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
@@ -1920,16 +1844,14 @@
final IChooserWrapper activity = (IChooserWrapper)
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets));
}
@Test @Ignore
- public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
+ public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() {
markWorkProfileUserAvailable();
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
@@ -1945,13 +1867,10 @@
return true;
};
- final IChooserWrapper activity = (IChooserWrapper)
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- // wait for the share sheet to expand
- Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs);
onView(first(allOf(
withText(workResolvedComponentInfos.get(0)
@@ -1964,8 +1883,6 @@
@Test
public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
@@ -1979,18 +1896,17 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
+ onView(withId(com.android.internal.R.id.contentPanel))
.perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_cross_profile_blocked"))
+ onView(withText(R.string.resolver_cross_profile_blocked))
.check(matches(isDisplayed()));
}
@Test
public void testWorkTab_workProfileDisabled_emptyStateShown() {
- // enable the work tab feature flag
markWorkProfileUserAvailable();
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
@@ -2002,22 +1918,19 @@
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
- ResolverActivity.ENABLE_TABBED_VIEW = true;
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
+ onView(withId(com.android.internal.R.id.contentPanel))
.perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_turn_on_work_apps"))
+ onView(withText(R.string.resolver_turn_on_work_apps))
.check(matches(isDisplayed()));
}
@Test
public void testWorkTab_noWorkAppsAvailable_emptyStateShown() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3);
@@ -2029,20 +1942,18 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
+ onView(withId(com.android.internal.R.id.contentPanel))
.perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_no_work_apps_available"))
+ onView(withText(R.string.resolver_no_work_apps_available))
.check(matches(isDisplayed()));
}
@Ignore // b/220067877
@Test
public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3);
@@ -2056,19 +1967,17 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
+ onView(withId(com.android.internal.R.id.contentPanel))
.perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_cross_profile_blocked"))
+ onView(withText(R.string.resolver_cross_profile_blocked))
.check(matches(isDisplayed()));
}
@Test
public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3);
@@ -2081,12 +1990,12 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
+ onView(withId(com.android.internal.R.id.contentPanel))
.perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_no_work_apps_available"))
+ onView(withText(R.string.resolver_no_work_apps_available))
.check(matches(isDisplayed()));
}
@@ -2115,7 +2024,7 @@
// timeout everywhere instead of introducing one to fix this particular test.
assertThat(activity.getAdapter().getCount(), is(2));
- onView(withIdFromRuntimeResource("profile_button")).check(doesNotExist());
+ onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist());
ResolveInfo[] chosen = new ResolveInfo[1];
ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> {
@@ -2128,53 +2037,11 @@
.perform(click());
waitForIdle();
- ChooserActivityLoggerFake logger =
- (ChooserActivityLoggerFake) activity.getChooserActivityLogger();
-
// TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- logger.removeCallsForUiEventsOfType(
- ChooserActivityLogger.SharesheetStandardEvent
- .SHARESHEET_DIRECT_LOAD_COMPLETE.getId());
-
- // SHARESHEET_TRIGGERED:
- assertThat(logger.event(0).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId()));
-
- // SHARESHEET_STARTED:
- assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED));
- assertThat(logger.get(1).intent, is(Intent.ACTION_SEND));
- assertThat(logger.get(1).mimeType, is("text/plain"));
- assertThat(logger.get(1).packageName, is(
- InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName()));
- assertThat(logger.get(1).appProvidedApp, is(0));
- assertThat(logger.get(1).appProvidedDirect, is(0));
- assertThat(logger.get(1).isWorkprofile, is(false));
- assertThat(logger.get(1).previewType, is(3));
-
- // SHARESHEET_APP_LOAD_COMPLETE:
- assertThat(logger.event(2).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId()));
-
- // Next are just artifacts of test set-up:
- assertThat(logger.event(3).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId()));
- assertThat(logger.event(4).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId()));
-
- // SHARESHEET_APP_TARGET_SELECTED:
- assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED));
- assertThat(logger.get(5).targetType,
- is(ChooserActivityLogger
- .SharesheetTargetSelectedEvent.SHARESHEET_APP_TARGET_SELECTED.getId()));
-
- // No more events.
- assertThat(logger.numCalls(), is(6));
}
- @Test @Ignore
- public void testDirectTargetLogging() throws InterruptedException {
+ @Test
+ public void testDirectTargetLogging() {
Intent sendIntent = createSendTextIntent();
// We need app targets for direct targets to get displayed
List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
@@ -2189,41 +2056,59 @@
Mockito.isA(List.class)))
.thenReturn(resolvedComponentInfos);
- // Create direct share target
- List<ChooserTarget> serviceTargets = createDirectShareTargets(1,
- resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName);
- ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0);
+ // 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();
- // Insert the direct share target
- Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>();
- directShareToShortcutInfos.put(serviceTargets.get(0), null);
- InstrumentationRegistry.getInstrumentation().runOnMainSync(
- () -> activity.getAdapter().addServiceResults(
- activity.createTestDisplayResolveInfo(sendIntent,
- ri,
- "testLabel",
- "testInfo",
- sendIntent,
- /* resolveInfoPresentationGetter */ null),
- serviceTargets,
- TARGET_TYPE_CHOOSER_TARGET,
- directShareToShortcutInfos)
+ // verify that ShortcutLoader was queried
+ ArgumentCaptor<DisplayResolveInfo[]> appTargets =
+ ArgumentCaptor.forClass(DisplayResolveInfo[].class);
+ verify(shortcutLoaders.get(0).first, times(1))
+ .queryShortcuts(appTargets.capture());
+
+ // send shortcuts
+ assertThat(
+ "Wrong number of app targets",
+ appTargets.getValue().length,
+ is(resolvedComponentInfos.size()));
+ 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<>()
);
- // Thread.sleep shouldn't be a thing in an integration test but it's
- // necessary here because of the way the code is structured
- // TODO: restructure the tests b/129870719
- Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs);
+ activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result));
+ waitForIdle();
assertThat("Chooser should have 3 targets (2 apps, 1 direct)",
activity.getAdapter().getCount(), is(3));
assertThat("Chooser should have exactly one selectable direct target",
activity.getAdapter().getSelectableServiceTargetCount(), is(1));
- assertThat("The resolver info must match the resolver info used to create the target",
- activity.getAdapter().getItem(0).getResolveInfo(), is(ri));
+ assertThat(
+ "The resolver info must match the resolver info used to create the target",
+ activity.getAdapter().getItem(0).getResolveInfo(),
+ is(resolvedComponentInfos.get(0).getResolveInfoAt(0)));
// Click on the direct target
String name = serviceTargets.get(0).getTitle().toString();
@@ -2231,34 +2116,18 @@
.perform(click());
waitForIdle();
- ChooserActivityLoggerFake logger =
- (ChooserActivityLoggerFake) activity.getChooserActivityLogger();
- assertThat(logger.numCalls(), is(6));
- // first one should be SHARESHEET_TRIGGERED uievent
- assertThat(logger.get(0).atomId, is(FrameworkStatsLog.UI_EVENT_REPORTED));
- assertThat(logger.get(0).event.getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId()));
- // second one should be SHARESHEET_STARTED event
- assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED));
- assertThat(logger.get(1).intent, is(Intent.ACTION_SEND));
- assertThat(logger.get(1).mimeType, is("text/plain"));
- assertThat(logger.get(1).packageName, is(
- InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName()));
- assertThat(logger.get(1).appProvidedApp, is(0));
- assertThat(logger.get(1).appProvidedDirect, is(0));
- assertThat(logger.get(1).isWorkprofile, is(false));
- assertThat(logger.get(1).previewType, is(3));
- // third one should be SHARESHEET_APP_LOAD_COMPLETE uievent
- assertThat(logger.get(2).atomId, is(FrameworkStatsLog.UI_EVENT_REPORTED));
- assertThat(logger.get(2).event.getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId()));
- // fourth and fifth are just artifacts of test set-up
- // sixth one should be ranking atom with SHARESHEET_COPY_TARGET_SELECTED event
- assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED));
- assertThat(logger.get(5).targetType,
- is(ChooserActivityLogger
- .SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId()));
+ ChooserActivityLogger logger = activity.getChooserActivityLogger();
+ ArgumentCaptor<Integer> typeCaptor = ArgumentCaptor.forClass(Integer.class);
+ verify(logger, times(1)).logShareTargetSelected(
+ eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE),
+ /* packageName= */ any(),
+ /* positionPicked= */ anyInt(),
+ /* directTargetAlsoRanked= */ anyInt(),
+ /* numCallerProvided= */ anyInt(),
+ /* directTargetHashed= */ any(),
+ /* isPinned= */ anyBoolean(),
+ /* successfullySelected= */ anyBoolean(),
+ /* selectionCost= */ anyLong());
}
@Test @Ignore
@@ -2290,44 +2159,7 @@
assertThat("Chooser should have no direct targets",
activity.getAdapter().getSelectableServiceTargetCount(), is(0));
- ChooserActivityLoggerFake logger =
- (ChooserActivityLoggerFake) activity.getChooserActivityLogger();
-
// TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- logger.removeCallsForUiEventsOfType(
- ChooserActivityLogger.SharesheetStandardEvent
- .SHARESHEET_DIRECT_LOAD_COMPLETE.getId());
-
- // SHARESHEET_TRIGGERED:
- assertThat(logger.event(0).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId()));
-
- // SHARESHEET_STARTED:
- assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED));
- assertThat(logger.get(1).intent, is(Intent.ACTION_SEND));
- assertThat(logger.get(1).mimeType, is("text/plain"));
- assertThat(logger.get(1).packageName, is(
- InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName()));
- assertThat(logger.get(1).appProvidedApp, is(0));
- assertThat(logger.get(1).appProvidedDirect, is(0));
- assertThat(logger.get(1).isWorkprofile, is(false));
- assertThat(logger.get(1).previewType, is(3));
-
- // SHARESHEET_APP_LOAD_COMPLETE:
- assertThat(logger.event(2).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId()));
-
- // SHARESHEET_EMPTY_DIRECT_SHARE_ROW:
- assertThat(logger.event(3).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId()));
-
- // Next is just an artifact of test set-up:
- assertThat(logger.event(4).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId()));
-
- assertThat(logger.numCalls(), is(5));
}
@Ignore // b/220067877
@@ -2351,58 +2183,14 @@
mActivityRule.launchActivity(Intent.createChooser(sendIntent, null));
waitForIdle();
- onView(withIdFromRuntimeResource("chooser_copy_button")).check(matches(isDisplayed()));
- onView(withIdFromRuntimeResource("chooser_copy_button")).perform(click());
-
- ChooserActivityLoggerFake logger =
- (ChooserActivityLoggerFake) activity.getChooserActivityLogger();
+ onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed()));
+ onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click());
// TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- logger.removeCallsForUiEventsOfType(
- ChooserActivityLogger.SharesheetStandardEvent
- .SHARESHEET_DIRECT_LOAD_COMPLETE.getId());
-
- // SHARESHEET_TRIGGERED:
- assertThat(logger.event(0).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId()));
-
- // SHARESHEET_STARTED:
- assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED));
- assertThat(logger.get(1).intent, is(Intent.ACTION_SEND));
- assertThat(logger.get(1).mimeType, is("text/plain"));
- assertThat(logger.get(1).packageName, is(
- InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName()));
- assertThat(logger.get(1).appProvidedApp, is(0));
- assertThat(logger.get(1).appProvidedDirect, is(0));
- assertThat(logger.get(1).isWorkprofile, is(false));
- assertThat(logger.get(1).previewType, is(3));
-
- // SHARESHEET_APP_LOAD_COMPLETE:
- assertThat(logger.event(2).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId()));
-
- // Next are just artifacts of test set-up:
- assertThat(logger.event(3).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId()));
- assertThat(logger.event(4).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId()));
-
- // SHARESHEET_COPY_TARGET_SELECTED:
- assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED));
- assertThat(logger.get(5).targetType,
- is(ChooserActivityLogger
- .SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()));
-
- // No more events.
- assertThat(logger.numCalls(), is(6));
}
@Test @Ignore("b/222124533")
public void testSwitchProfileLogging() throws InterruptedException {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
@@ -2416,134 +2204,16 @@
final IChooserWrapper activity = (IChooserWrapper)
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_personal_tab")).perform(click());
+ onView(withText(R.string.resolver_personal_tab)).perform(click());
waitForIdle();
- ChooserActivityLoggerFake logger =
- (ChooserActivityLoggerFake) activity.getChooserActivityLogger();
-
// TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events.
- logger.removeCallsForUiEventsOfType(
- ChooserActivityLogger.SharesheetStandardEvent
- .SHARESHEET_DIRECT_LOAD_COMPLETE.getId());
-
- // SHARESHEET_TRIGGERED:
- assertThat(logger.event(0).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId()));
-
- // SHARESHEET_STARTED:
- assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED));
- assertThat(logger.get(1).intent, is(Intent.ACTION_SEND));
- assertThat(logger.get(1).mimeType, is(TEST_MIME_TYPE));
- assertThat(logger.get(1).packageName, is(
- InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName()));
- assertThat(logger.get(1).appProvidedApp, is(0));
- assertThat(logger.get(1).appProvidedDirect, is(0));
- assertThat(logger.get(1).isWorkprofile, is(false));
- assertThat(logger.get(1).previewType, is(3));
-
- // SHARESHEET_APP_LOAD_COMPLETE:
- assertThat(logger.event(2).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId()));
-
- // Next is just an artifact of test set-up:
- assertThat(logger.event(3).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId()));
-
- // SHARESHEET_PROFILE_CHANGED:
- assertThat(logger.event(4).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent
- .SHARESHEET_PROFILE_CHANGED.getId()));
-
- // Repeat the loading steps in the new profile:
-
- // SHARESHEET_APP_LOAD_COMPLETE:
- assertThat(logger.event(5).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId()));
-
- // Next is again an artifact of test set-up:
- assertThat(logger.event(6).getId(),
- is(ChooserActivityLogger
- .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId()));
-
- // SHARESHEET_PROFILE_CHANGED:
- assertThat(logger.event(7).getId(),
- is(ChooserActivityLogger.SharesheetStandardEvent
- .SHARESHEET_PROFILE_CHANGED.getId()));
-
- // No more events (this profile was already loaded).
- assertThat(logger.numCalls(), is(8));
- }
-
- @Test
- public void testAutolaunch_singleTarget_wifthWorkProfileAndTabbedViewOff_noAutolaunch() {
- ResolverActivity.ENABLE_TABBED_VIEW = false;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10);
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resolverListController
- .getResolversForIntent(
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.isA(List.class)))
- .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
- waitForIdle();
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
-
- assertTrue(chosen[0] == null);
- }
-
- @Test
- public void testAutolaunch_singleTarget_noWorkProfile_autolaunch() {
- ResolverActivity.ENABLE_TABBED_VIEW = false;
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTest(1);
- when(
- ChooserActivityOverrideData
- .getInstance()
- .resolverListController
- .getResolversForIntent(
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.anyBoolean(),
- Mockito.isA(List.class)))
- .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
- ResolveInfo[] chosen = new ResolveInfo[1];
- ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> {
- chosen[0] = targetInfo.getResolveInfo();
- return true;
- };
- waitForIdle();
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
-
- assertThat(chosen[0], is(personalResolvedComponentInfos.get(0).getResolveInfoAt(0)));
}
@Test
public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_autolaunch() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
@@ -2559,7 +2229,7 @@
return true;
};
- mActivityRule.launchActivity(sendIntent);
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test"));
waitForIdle();
assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0)));
@@ -2591,7 +2261,7 @@
when(
ChooserActivityOverrideData
.getInstance().packageManager
- .resolveActivity(any(Intent.class), anyInt()))
+ .resolveActivity(any(Intent.class), any()))
.thenReturn(ri);
waitForIdle();
@@ -2605,8 +2275,6 @@
@Test
public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
int workProfileTargets = 1;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
@@ -2624,7 +2292,7 @@
ChooserActivityOverrideData
.getInstance()
.packageManager
- .resolveActivity(any(Intent.class), anyInt()))
+ .resolveActivity(any(Intent.class), any()))
.thenReturn(createFakeResolveInfo());
waitForIdle();
@@ -2637,8 +2305,6 @@
@Test
public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
int workProfileTargets = 4;
List<ResolvedComponentInfo> personalResolvedComponentInfos =
@@ -2657,24 +2323,22 @@
ChooserActivityOverrideData
.getInstance()
.packageManager
- .resolveActivity(any(Intent.class), anyInt()))
+ .resolveActivity(any(Intent.class), any()))
.thenReturn(createFakeResolveInfo());
mActivityRule.launchActivity(chooserIntent);
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
+ onView(withId(com.android.internal.R.id.contentPanel))
.perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_cross_profile_blocked"))
+ onView(withText(R.string.resolver_cross_profile_blocked))
.check(matches(isDisplayed()));
}
@Test
public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
markWorkProfileUserAvailable();
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTest(3);
@@ -2691,17 +2355,17 @@
ChooserActivityOverrideData
.getInstance()
.packageManager
- .resolveActivity(any(Intent.class), anyInt()))
+ .resolveActivity(any(Intent.class), any()))
.thenReturn(createFakeResolveInfo());
mActivityRule.launchActivity(chooserIntent);
waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
+ onView(withId(com.android.internal.R.id.contentPanel))
.perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- onView(withTextFromRuntimeResource("resolver_no_work_apps_available"))
+ onView(withText(R.string.resolver_no_work_apps_available))
.check(matches(isDisplayed()));
}
@@ -2726,7 +2390,7 @@
ChooserActivityOverrideData
.getInstance()
.packageManager
- .resolveActivity(any(Intent.class), anyInt()))
+ .resolveActivity(any(Intent.class), any()))
.thenReturn(ri);
waitForIdle();
@@ -2740,150 +2404,35 @@
}
@Test
- public void testWorkTab_selectingWorkTabWithPausedWorkProfile_directShareTargetsNotQueried() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
+ public void test_query_shortcut_loader_for_the_selected_tab() {
markWorkProfileUserAvailable();
List<ResolvedComponentInfo> personalResolvedComponentInfos =
createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
List<ResolvedComponentInfo> workResolvedComponentInfos =
createResolvedComponentsForTest(3);
setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true;
- boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false };
- ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets =
- chooserListAdapter -> {
- isQueryDirectShareCalledOnWorkProfile[0] =
- (chooserListAdapter.getUserHandle().getIdentifier() == 10);
- return null;
- };
+ ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class);
+ ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class);
+ final SparseArray<ShortcutLoader> shortcutLoaders = new SparseArray<>();
+ shortcutLoaders.put(0, personalProfileShortcutLoader);
+ shortcutLoaders.put(10, workProfileShortcutLoader);
+ ChooserActivityOverrideData.getInstance().shortcutLoaderFactory =
+ (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null);
Intent sendIntent = createSendTextIntent();
sendIntent.setType(TEST_MIME_TYPE);
mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
+ onView(withId(com.android.internal.R.id.contentPanel))
.perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
waitForIdle();
- assertFalse("Direct share targets were queried on a paused work profile",
- isQueryDirectShareCalledOnWorkProfile[0]);
- }
+ verify(personalProfileShortcutLoader, times(1)).queryShortcuts(any());
- @Test
- public void testWorkTab_selectingWorkTabWithNotRunningWorkUser_directShareTargetsNotQueried() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false;
- boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false };
- ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets =
- chooserListAdapter -> {
- isQueryDirectShareCalledOnWorkProfile[0] =
- (chooserListAdapter.getUserHandle().getIdentifier() == 10);
- return null;
- };
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
- .perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
+ onView(withText(R.string.resolver_work_tab)).perform(click());
waitForIdle();
- assertFalse("Direct share targets were queried on a locked work profile user",
- isQueryDirectShareCalledOnWorkProfile[0]);
- }
-
- @Test
- public void testWorkTab_workUserNotRunning_workTargetsShown() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
- ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false;
-
- final ChooserActivity activity =
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- final IChooserWrapper wrapper = (IChooserWrapper) activity;
- waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel")).perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
- waitForIdle();
-
- assertEquals(3, wrapper.getWorkListAdapter().getCount());
- }
-
- @Test
- public void testWorkTab_selectingWorkTabWithLockedWorkUser_directShareTargetsNotQueried() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false;
- boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false };
- ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets =
- chooserListAdapter -> {
- isQueryDirectShareCalledOnWorkProfile[0] =
- (chooserListAdapter.getUserHandle().getIdentifier() == 10);
- return null;
- };
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
-
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
- .perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
- waitForIdle();
-
- assertFalse("Direct share targets were queried on a locked work profile user",
- isQueryDirectShareCalledOnWorkProfile[0]);
- }
-
- @Test
- public void testWorkTab_workUserLocked_workTargetsShown() {
- // enable the work tab feature flag
- ResolverActivity.ENABLE_TABBED_VIEW = true;
- markWorkProfileUserAvailable();
- List<ResolvedComponentInfo> personalResolvedComponentInfos =
- createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10);
- List<ResolvedComponentInfo> workResolvedComponentInfos =
- createResolvedComponentsForTest(3);
- setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
- Intent sendIntent = createSendTextIntent();
- sendIntent.setType(TEST_MIME_TYPE);
- ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false;
-
- final ChooserActivity activity =
- mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test"));
- final IChooserWrapper wrapper = (IChooserWrapper) activity;
- waitForIdle();
- onView(withIdFromRuntimeResource("contentPanel"))
- .perform(swipeUp());
- onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click());
- waitForIdle();
-
- assertEquals(3, wrapper.getWorkListAdapter().getCount());
+ verify(workProfileShortcutLoader, times(1)).queryShortcuts(any());
}
private Intent createChooserIntent(Intent intent, Intent[] initialIntents) {
@@ -3092,21 +2641,6 @@
return shortcuts;
}
- private void assertCorrectShortcutToChooserTargetConversion(List<ShareShortcutInfo> shortcuts,
- List<ChooserTarget> chooserTargets, int[] expectedOrder, float[] expectedScores) {
- assertEquals(expectedOrder.length, chooserTargets.size());
- for (int i = 0; i < chooserTargets.size(); i++) {
- ChooserTarget ct = chooserTargets.get(i);
- ShortcutInfo si = shortcuts.get(expectedOrder[i]).getShortcutInfo();
- ComponentName cn = shortcuts.get(expectedOrder[i]).getTargetComponent();
-
- assertEquals(si.getId(), ct.getIntentExtras().getString(Intent.EXTRA_SHORTCUT_ID));
- assertEquals(si.getShortLabel(), ct.getTitle());
- assertThat(Math.abs(expectedScores[i] - ct.getScore()) < 0.000001, is(true));
- assertEquals(cn.flattenToString(), ct.getComponentName().flattenToString());
- }
- }
-
private void markWorkProfileUserAvailable() {
ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10);
}
@@ -3147,14 +2681,6 @@
.thenReturn(new ArrayList<>(personalResolvedComponentInfos));
}
- private Matcher<View> withIdFromRuntimeResource(String id) {
- return withId(getRuntimeResourceId(id, "id"));
- }
-
- private Matcher<View> withTextFromRuntimeResource(String id) {
- return withText(getRuntimeResourceId(id, "string"));
- }
-
private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) {
return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount));
}
@@ -3214,25 +2740,17 @@
.thenReturn(targetsPerRow);
}
- // ChooserWrapperActivity inherits from the framework ChooserActivity, so if the framework
- // resources have been updated since the framework was last built/pushed, the inherited behavior
- // (which is the focus of our testing) will still be implemented in terms of the old resource
- // IDs; then when we try to assert those IDs in tests (e.g. `onView(withText(R.string.foo))`),
- // the expected values won't match. The tests can instead call this method (with the same
- // general semantics as Resources#getIdentifier() e.g. `getRuntimeResourceId("foo", "string")`)
- // to refer to the resource by that name in the runtime chooser, regardless of whether the
- // framework code on the device is up-to-date.
- // TODO: is there a better way to do this? (Other than abandoning inheritance-based DI wrapper?)
- private int getRuntimeResourceId(String name, String defType) {
- int id = -1;
- if (ChooserActivityOverrideData.getInstance().resources != null) {
- id = ChooserActivityOverrideData.getInstance().resources.getIdentifier(
- name, defType, "android");
- } else {
- id = mActivityRule.getActivity().getResources().getIdentifier(name, defType, "android");
- }
- assertThat(id, greaterThan(0));
-
- return id;
+ private SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>>
+ createShortcutLoaderFactory() {
+ 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;
+ };
+ return shortcutLoaders;
}
}
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
new file mode 100644
index 0000000..f1febed
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java
@@ -0,0 +1,467 @@
+/*
+ * 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 android.testing.PollingCheck.waitFor;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.action.ViewActions.swipeUp;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.isSelected;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import static com.android.intentresolver.ChooserWrapperActivity.sOverrides;
+import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER;
+import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER;
+import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER;
+import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER;
+import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER;
+import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL;
+import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK;
+
+import static org.hamcrest.CoreMatchers.not;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import android.companion.DeviceFilter;
+import android.content.Intent;
+import android.os.UserHandle;
+
+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;
+
+import junit.framework.AssertionFailedError;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+@DeviceFilter.MediumType
+@RunWith(Parameterized.class)
+public class UnbundledChooserActivityWorkProfileTest {
+
+ private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry
+ .getInstrumentation().getTargetContext().getUser();
+ private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10);
+
+ @Rule
+ public ActivityTestRule<ChooserWrapperActivity> mActivityRule =
+ new ActivityTestRule<>(ChooserWrapperActivity.class, false,
+ false);
+ private final TestCase mTestCase;
+
+ public UnbundledChooserActivityWorkProfileTest(TestCase testCase) {
+ mTestCase = testCase;
+ }
+
+ @Before
+ public void cleanOverrideData() {
+ // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the
+ // permissions we require (which we'll read from the manifest at runtime).
+ InstrumentationRegistry
+ .getInstrumentation()
+ .getUiAutomation()
+ .adoptShellPermissionIdentity();
+
+ sOverrides.reset();
+ }
+
+ @Test
+ public void testBlocker() {
+ setUpPersonalAndWorkComponentInfos();
+ sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents();
+ sOverrides.myUserId = mTestCase.getMyUserHandle().getIdentifier();
+
+ launchActivity(mTestCase.getIsSendAction());
+ switchToTab(mTestCase.getTab());
+
+ switch (mTestCase.getExpectedBlocker()) {
+ case NO_BLOCKER:
+ assertNoBlockerDisplayed();
+ break;
+ case PERSONAL_PROFILE_SHARE_BLOCKER:
+ assertCantSharePersonalAppsBlockerDisplayed();
+ break;
+ case WORK_PROFILE_SHARE_BLOCKER:
+ assertCantShareWorkAppsBlockerDisplayed();
+ break;
+ case PERSONAL_PROFILE_ACCESS_BLOCKER:
+ assertCantAccessPersonalAppsBlockerDisplayed();
+ break;
+ case WORK_PROFILE_ACCESS_BLOCKER:
+ assertCantAccessWorkAppsBlockerDisplayed();
+ break;
+ }
+ }
+
+ @Parameterized.Parameters(name = "{0}")
+ public static Collection tests() {
+ return Arrays.asList(
+ new TestCase(
+ /* isSendAction= */ true,
+ /* hasCrossProfileIntents= */ true,
+ /* myUserHandle= */ WORK_USER_HANDLE,
+ /* tab= */ WORK,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ true,
+ /* hasCrossProfileIntents= */ false,
+ /* myUserHandle= */ WORK_USER_HANDLE,
+ /* tab= */ WORK,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ true,
+ /* hasCrossProfileIntents= */ true,
+ /* myUserHandle= */ PERSONAL_USER_HANDLE,
+ /* tab= */ WORK,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ true,
+ /* hasCrossProfileIntents= */ false,
+ /* myUserHandle= */ PERSONAL_USER_HANDLE,
+ /* tab= */ WORK,
+ /* expectedBlocker= */ WORK_PROFILE_SHARE_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ true,
+ /* hasCrossProfileIntents= */ true,
+ /* myUserHandle= */ WORK_USER_HANDLE,
+ /* tab= */ PERSONAL,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ true,
+ /* hasCrossProfileIntents= */ false,
+ /* myUserHandle= */ WORK_USER_HANDLE,
+ /* tab= */ PERSONAL,
+ /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ true,
+ /* hasCrossProfileIntents= */ true,
+ /* myUserHandle= */ PERSONAL_USER_HANDLE,
+ /* tab= */ PERSONAL,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ true,
+ /* hasCrossProfileIntents= */ false,
+ /* myUserHandle= */ PERSONAL_USER_HANDLE,
+ /* tab= */ PERSONAL,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ false,
+ /* hasCrossProfileIntents= */ true,
+ /* myUserHandle= */ WORK_USER_HANDLE,
+ /* tab= */ WORK,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ false,
+ /* hasCrossProfileIntents= */ false,
+ /* myUserHandle= */ WORK_USER_HANDLE,
+ /* tab= */ WORK,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ false,
+ /* hasCrossProfileIntents= */ true,
+ /* myUserHandle= */ PERSONAL_USER_HANDLE,
+ /* tab= */ WORK,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ false,
+ /* hasCrossProfileIntents= */ false,
+ /* myUserHandle= */ PERSONAL_USER_HANDLE,
+ /* tab= */ WORK,
+ /* expectedBlocker= */ WORK_PROFILE_ACCESS_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ false,
+ /* hasCrossProfileIntents= */ true,
+ /* myUserHandle= */ WORK_USER_HANDLE,
+ /* tab= */ PERSONAL,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ false,
+ /* hasCrossProfileIntents= */ false,
+ /* myUserHandle= */ WORK_USER_HANDLE,
+ /* tab= */ PERSONAL,
+ /* expectedBlocker= */ PERSONAL_PROFILE_ACCESS_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ false,
+ /* hasCrossProfileIntents= */ true,
+ /* myUserHandle= */ PERSONAL_USER_HANDLE,
+ /* tab= */ PERSONAL,
+ /* expectedBlocker= */ NO_BLOCKER
+ ),
+ new TestCase(
+ /* isSendAction= */ false,
+ /* hasCrossProfileIntents= */ false,
+ /* myUserHandle= */ PERSONAL_USER_HANDLE,
+ /* tab= */ PERSONAL,
+ /* expectedBlocker= */ NO_BLOCKER
+ )
+ );
+ }
+
+ private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile(
+ int numberOfResults, int userId) {
+ List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
+ for (int i = 0; i < numberOfResults; i++) {
+ infoList.add(
+ ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId));
+ }
+ return infoList;
+ }
+
+ private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults) {
+ List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults);
+ for (int i = 0; i < numberOfResults; i++) {
+ infoList.add(ResolverDataProvider.createResolvedComponentInfo(i));
+ }
+ return infoList;
+ }
+
+ private void setUpPersonalAndWorkComponentInfos() {
+ markWorkProfileUserAvailable();
+ int workProfileTargets = 4;
+ List<ResolvedComponentInfo> personalResolvedComponentInfos =
+ createResolvedComponentsForTestWithOtherProfile(3,
+ /* userId */ WORK_USER_HANDLE.getIdentifier());
+ List<ResolvedComponentInfo> workResolvedComponentInfos =
+ createResolvedComponentsForTest(workProfileTargets);
+ setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos);
+ }
+
+ private void setupResolverControllers(
+ List<ResolvedComponentInfo> personalResolvedComponentInfos,
+ List<ResolvedComponentInfo> workResolvedComponentInfos) {
+ when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ Mockito.isA(List.class)))
+ .thenReturn(new ArrayList<>(personalResolvedComponentInfos));
+ when(sOverrides.workResolverListController.getResolversForIntent(Mockito.anyBoolean(),
+ Mockito.anyBoolean(),
+ 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));
+ }
+
+ private void waitForIdle() {
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ }
+
+ private void markWorkProfileUserAvailable() {
+ ChooserWrapperActivity.sOverrides.workProfileUserHandle = WORK_USER_HANDLE;
+ }
+
+ private void assertCantAccessWorkAppsBlockerDisplayed() {
+ onView(withText(R.string.resolver_cross_profile_blocked))
+ .check(matches(isDisplayed()));
+ onView(withText(R.string.resolver_cant_access_work_apps_explanation))
+ .check(matches(isDisplayed()));
+ }
+
+ private void assertCantAccessPersonalAppsBlockerDisplayed() {
+ onView(withText(R.string.resolver_cross_profile_blocked))
+ .check(matches(isDisplayed()));
+ onView(withText(R.string.resolver_cant_access_personal_apps_explanation))
+ .check(matches(isDisplayed()));
+ }
+
+ private void assertCantShareWorkAppsBlockerDisplayed() {
+ onView(withText(R.string.resolver_cross_profile_blocked))
+ .check(matches(isDisplayed()));
+ onView(withText(R.string.resolver_cant_share_with_work_apps_explanation))
+ .check(matches(isDisplayed()));
+ }
+
+ private void assertCantSharePersonalAppsBlockerDisplayed() {
+ onView(withText(R.string.resolver_cross_profile_blocked))
+ .check(matches(isDisplayed()));
+ onView(withText(R.string.resolver_cant_share_with_personal_apps_explanation))
+ .check(matches(isDisplayed()));
+ }
+
+ private void assertNoBlockerDisplayed() {
+ try {
+ onView(withText(R.string.resolver_cross_profile_blocked))
+ .check(matches(not(isDisplayed())));
+ } catch (NoMatchingViewException ignored) {
+ }
+ }
+
+ private void switchToTab(Tab tab) {
+ final int stringId = tab == Tab.WORK ? R.string.resolver_work_tab
+ : R.string.resolver_personal_tab;
+
+ waitFor(() -> {
+ onView(withText(stringId)).perform(click());
+ waitForIdle();
+
+ try {
+ onView(withText(stringId)).check(matches(isSelected()));
+ return true;
+ } catch (AssertionFailedError e) {
+ return false;
+ }
+ });
+
+ onView(withId(R.id.contentPanel))
+ .perform(swipeUp());
+ waitForIdle();
+ }
+
+ private Intent createTextIntent(boolean isSendAction) {
+ Intent sendIntent = new Intent();
+ if (isSendAction) {
+ sendIntent.setAction(Intent.ACTION_SEND);
+ }
+ sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending");
+ sendIntent.setType("text/plain");
+ return sendIntent;
+ }
+
+ private void launchActivity(boolean isSendAction) {
+ Intent sendIntent = createTextIntent(isSendAction);
+ mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test"));
+ waitForIdle();
+ }
+
+ public static class TestCase {
+ private final boolean mIsSendAction;
+ private final boolean mHasCrossProfileIntents;
+ private final UserHandle mMyUserHandle;
+ private final Tab mTab;
+ private final ExpectedBlocker mExpectedBlocker;
+
+ public enum ExpectedBlocker {
+ NO_BLOCKER,
+ PERSONAL_PROFILE_SHARE_BLOCKER,
+ WORK_PROFILE_SHARE_BLOCKER,
+ PERSONAL_PROFILE_ACCESS_BLOCKER,
+ WORK_PROFILE_ACCESS_BLOCKER
+ }
+
+ public enum Tab {
+ WORK,
+ PERSONAL
+ }
+
+ public TestCase(boolean isSendAction, boolean hasCrossProfileIntents,
+ UserHandle myUserHandle, Tab tab, ExpectedBlocker expectedBlocker) {
+ mIsSendAction = isSendAction;
+ mHasCrossProfileIntents = hasCrossProfileIntents;
+ mMyUserHandle = myUserHandle;
+ mTab = tab;
+ mExpectedBlocker = expectedBlocker;
+ }
+
+ public boolean getIsSendAction() {
+ return mIsSendAction;
+ }
+
+ public boolean hasCrossProfileIntents() {
+ return mHasCrossProfileIntents;
+ }
+
+ public UserHandle getMyUserHandle() {
+ return mMyUserHandle;
+ }
+
+ public Tab getTab() {
+ return mTab;
+ }
+
+ public ExpectedBlocker getExpectedBlocker() {
+ return mExpectedBlocker;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder result = new StringBuilder("test");
+
+ if (mTab == WORK) {
+ result.append("WorkTab_");
+ } else {
+ result.append("PersonalTab_");
+ }
+
+ if (mIsSendAction) {
+ result.append("sendAction_");
+ } else {
+ result.append("notSendAction_");
+ }
+
+ if (mHasCrossProfileIntents) {
+ result.append("hasCrossProfileIntents_");
+ } else {
+ result.append("doesNotHaveCrossProfileIntents_");
+ }
+
+ if (mMyUserHandle.equals(PERSONAL_USER_HANDLE)) {
+ result.append("myUserIsPersonal_");
+ } else {
+ result.append("myUserIsWork_");
+ }
+
+ if (mExpectedBlocker == ExpectedBlocker.NO_BLOCKER) {
+ result.append("thenNoBlocker");
+ } else if (mExpectedBlocker == PERSONAL_PROFILE_ACCESS_BLOCKER) {
+ result.append("thenAccessBlockerOnPersonalProfile");
+ } else if (mExpectedBlocker == PERSONAL_PROFILE_SHARE_BLOCKER) {
+ result.append("thenShareBlockerOnPersonalProfile");
+ } else if (mExpectedBlocker == WORK_PROFILE_ACCESS_BLOCKER) {
+ result.append("thenAccessBlockerOnWorkProfile");
+ } else if (mExpectedBlocker == WORK_PROFILE_SHARE_BLOCKER) {
+ result.append("thenShareBlockerOnWorkProfile");
+ }
+
+ return result.toString();
+ }
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
new file mode 100644
index 0000000..7c2b07a
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt
@@ -0,0 +1,192 @@
+/*
+ * 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.chooser
+
+import android.app.prediction.AppTarget
+import android.app.prediction.AppTargetId
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.content.pm.ResolveInfo
+import android.os.UserHandle
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.intentresolver.createChooserTarget
+import com.android.intentresolver.createShortcutInfo
+import com.android.intentresolver.mock
+import com.android.intentresolver.ResolverDataProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class TargetInfoTest {
+ private val context = InstrumentationRegistry.getInstrumentation().getContext()
+
+ @Test
+ fun testNewEmptyTargetInfo() {
+ val info = NotSelectableTargetInfo.newEmptyTargetInfo()
+ assertThat(info.isEmptyTargetInfo()).isTrue()
+ assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model.
+ assertThat(info.hasDisplayIcon()).isFalse()
+ assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull()
+ }
+
+ @Test
+ fun testNewPlaceholderTargetInfo() {
+ val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context)
+ 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.
+ }
+
+ @Test
+ fun testNewSelectableTargetInfo() {
+ val resolvedIntent = Intent()
+ 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(),
+ )
+ assertThat(targetInfo.isSelectableTargetInfo).isTrue()
+ assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model.
+ assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(baseDisplayInfo)
+ assertThat(targetInfo.chooserTargetComponentName).isEqualTo(chooserTarget.componentName)
+ assertThat(targetInfo.directShareShortcutId).isEqualTo(shortcutInfo.id)
+ assertThat(targetInfo.directShareShortcutInfo).isSameInstanceAs(shortcutInfo)
+ assertThat(targetInfo.directShareAppTarget).isSameInstanceAs(appTarget)
+ assertThat(targetInfo.resolvedIntent).isSameInstanceAs(resolvedIntent)
+ // TODO: make more meaningful assertions about the behavior of a selectable target.
+ }
+
+ @Test
+ fun test_SelectableTargetInfo_componentName_no_source_info() {
+ val chooserTarget = createChooserTarget(
+ "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id")
+ val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3)
+ val appTarget = AppTarget(
+ AppTargetId("id"),
+ chooserTarget.componentName.packageName,
+ chooserTarget.componentName.className,
+ UserHandle.CURRENT)
+ val pkgName = "org.package"
+ val className = "MainActivity"
+ val backupResolveInfo = ResolveInfo().apply {
+ activityInfo = ActivityInfo().apply {
+ packageName = pkgName
+ name = className
+ }
+ }
+
+ val targetInfo = SelectableTargetInfo.newSelectableTargetInfo(
+ null,
+ backupResolveInfo,
+ mock(),
+ chooserTarget,
+ 0.1f,
+ shortcutInfo,
+ appTarget,
+ mock(),
+ )
+ assertThat(targetInfo.resolvedComponentName).isEqualTo(ComponentName(pkgName, className))
+ }
+
+ @Test
+ fun testNewDisplayResolveInfo() {
+ val intent = Intent(Intent.ACTION_SEND)
+ intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending")
+ intent.setType("text/plain")
+
+ val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0)
+
+ val targetInfo = DisplayResolveInfo.newDisplayResolveInfo(
+ intent,
+ resolveInfo,
+ "label",
+ "extended info",
+ intent,
+ /* resolveInfoPresentationGetter= */ null)
+ assertThat(targetInfo.isDisplayResolveInfo()).isTrue()
+ assertThat(targetInfo.isMultiDisplayResolveInfo()).isFalse()
+ assertThat(targetInfo.isChooserTargetInfo()).isFalse()
+ }
+
+ @Test
+ fun testNewMultiDisplayResolveInfo() {
+ val intent = Intent(Intent.ACTION_SEND)
+ intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending")
+ intent.setType("text/plain")
+
+ val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0)
+ val firstTargetInfo = DisplayResolveInfo.newDisplayResolveInfo(
+ intent,
+ resolveInfo,
+ "label 1",
+ "extended info 1",
+ intent,
+ /* resolveInfoPresentationGetter= */ null)
+ val secondTargetInfo = DisplayResolveInfo.newDisplayResolveInfo(
+ intent,
+ resolveInfo,
+ "label 2",
+ "extended info 2",
+ intent,
+ /* resolveInfoPresentationGetter= */ null)
+
+ val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo(
+ listOf(firstTargetInfo, secondTargetInfo))
+
+ assertThat(multiTargetInfo.isMultiDisplayResolveInfo()).isTrue()
+ assertThat(multiTargetInfo.isDisplayResolveInfo()).isTrue() // From legacy inheritance.
+ assertThat(multiTargetInfo.isChooserTargetInfo()).isFalse()
+
+ assertThat(multiTargetInfo.getExtendedInfo()).isNull()
+
+ assertThat(multiTargetInfo.getAllDisplayTargets())
+ .containsExactly(firstTargetInfo, secondTargetInfo)
+
+ assertThat(multiTargetInfo.hasSelected()).isFalse()
+ assertThat(multiTargetInfo.getSelectedTarget()).isNull()
+
+ multiTargetInfo.setSelected(1)
+
+ assertThat(multiTargetInfo.hasSelected()).isTrue()
+ assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo)
+
+ // TODO: consider exercising activity-start behavior.
+ // TODO: consider exercising DisplayResolveInfo base class behavior.
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
new file mode 100644
index 0000000..448718c
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2019 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.model;
+
+import static junit.framework.Assert.assertEquals;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ResolveInfo;
+import android.os.Message;
+
+import androidx.test.InstrumentationRegistry;
+
+import com.android.intentresolver.ResolverActivity;
+
+import org.junit.Test;
+
+import java.util.List;
+
+public class AbstractResolverComparatorTest {
+
+ @Test
+ public void testPinned() {
+ ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.ResolvedComponentInfo(
+ new ComponentName("package", "class"), new Intent(), new ResolveInfo()
+ );
+ r1.setPinned(true);
+
+ ResolverActivity.ResolvedComponentInfo r2 = new ResolverActivity.ResolvedComponentInfo(
+ new ComponentName("zackage", "zlass"), new Intent(), new ResolveInfo()
+ );
+
+ Context context = InstrumentationRegistry.getTargetContext();
+ AbstractResolverComparator comparator = getTestComparator(context);
+
+ assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2));
+ assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1));
+ }
+
+
+ @Test
+ public void testBothPinned() {
+ ResolveInfo pmInfo1 = new ResolveInfo();
+ pmInfo1.activityInfo = new ActivityInfo();
+ pmInfo1.activityInfo.packageName = "aaa";
+
+ ResolverActivity.ResolvedComponentInfo r1 = new ResolverActivity.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(
+ new ComponentName("zackage", "zlass"), new Intent(), pmInfo2);
+ r2.setPinned(true);
+
+ Context context = InstrumentationRegistry.getTargetContext();
+ AbstractResolverComparator comparator = getTestComparator(context);
+
+ assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2));
+ }
+
+ private AbstractResolverComparator getTestComparator(Context context) {
+ Intent intent = new Intent();
+
+ AbstractResolverComparator testComparator =
+ new AbstractResolverComparator(context, intent) {
+
+ @Override
+ int compare(ResolveInfo lhs, ResolveInfo rhs) {
+ // Used for testing pinning, so we should never get here --- the overrides
+ // should determine the result instead.
+ return 1;
+ }
+
+ @Override
+ void doCompute(List<ResolverActivity.ResolvedComponentInfo> targets) {}
+
+ @Override
+ public float getScore(ComponentName name) {
+ return 0;
+ }
+
+ @Override
+ void handleResultMessage(Message message) {}
+ };
+ return testComparator;
+ }
+
+}
diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
new file mode 100644
index 0000000..5756a0c
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt
@@ -0,0 +1,329 @@
+/*
+ * 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.prediction.AppPredictor
+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.ShortcutManager
+import android.os.UserHandle
+import android.os.UserManager
+import androidx.test.filters.SmallTest
+import com.android.intentresolver.any
+import com.android.intentresolver.chooser.DisplayResolveInfo
+import com.android.intentresolver.createAppTarget
+import com.android.intentresolver.createShareShortcutInfo
+import com.android.intentresolver.createShortcutInfo
+import com.android.intentresolver.mock
+import com.android.intentresolver.whenever
+import org.junit.Assert.assertArrayEquals
+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.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import java.util.concurrent.Executor
+import java.util.function.Consumer
+
+@SmallTest
+class ShortcutLoaderTest {
+ private val appInfo = ApplicationInfo().apply {
+ enabled = true
+ flags = 0
+ }
+ private val pm = mock<PackageManager> {
+ whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo)
+ }
+ private val context = mock<Context> {
+ whenever(packageManager).thenReturn(pm)
+ whenever(createContextAsUser(any(), anyInt())).thenReturn(this)
+ }
+ private val executor = ImmediateExecutor()
+ private val intentFilter = mock<IntentFilter>()
+ private val appPredictor = mock<ShortcutLoader.AppPredictorProxy>()
+ private val callback = mock<Consumer<ShortcutLoader.Result>>()
+
+ @Test
+ fun test_app_predictor_result() {
+ val componentName = ComponentName("pkg", "Class")
+ val appTarget = mock<DisplayResolveInfo> {
+ whenever(resolvedComponentName).thenReturn(componentName)
+ }
+ val appTargets = arrayOf(appTarget)
+ val testSubject = ShortcutLoader(
+ context,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ executor,
+ executor,
+ callback
+ )
+
+ 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
+ createAppTarget(
+ createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
+ )
+ )
+ appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts)
+
+ val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java)
+ verify(callback, times(1)).accept(resultCaptor.capture())
+
+ val result = resultCaptor.value
+ assertTrue("An app predictor result is expected", result.isFromAppPredictor)
+ assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets)
+ assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
+ assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
+ for (shortcut in result.shortcutsByApp[0].shortcuts) {
+ assertEquals(
+ "Wrong AppTarget in the cache",
+ matchingAppTarget,
+ result.directShareAppTargetCache[shortcut]
+ )
+ assertEquals(
+ "Wrong ShortcutInfo in the cache",
+ matchingShortcutInfo,
+ result.directShareShortcutInfoCache[shortcut]
+ )
+ }
+ }
+
+ @Test
+ fun test_shortcut_manager_result() {
+ val componentName = ComponentName("pkg", "Class")
+ val appTarget = mock<DisplayResolveInfo> {
+ whenever(resolvedComponentName).thenReturn(componentName)
+ }
+ val appTargets = arrayOf(appTarget)
+ val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1)
+ val shortcutManagerResult = listOf(
+ ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
+ // mismatching shortcut
+ createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
+ )
+ val shortcutManager = mock<ShortcutManager> {
+ whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult)
+ }
+ whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
+ val testSubject = ShortcutLoader(
+ context,
+ null,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ executor,
+ executor,
+ callback
+ )
+
+ testSubject.queryShortcuts(appTargets)
+
+ val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java)
+ verify(callback, times(1)).accept(resultCaptor.capture())
+
+ val result = resultCaptor.value
+ assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
+ assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets)
+ assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
+ assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
+ for (shortcut in result.shortcutsByApp[0].shortcuts) {
+ assertTrue(
+ "AppTargets are not expected the cache of a ShortcutManager result",
+ result.directShareAppTargetCache.isEmpty()
+ )
+ assertEquals(
+ "Wrong ShortcutInfo in the cache",
+ matchingShortcutInfo,
+ result.directShareShortcutInfoCache[shortcut]
+ )
+ }
+ }
+
+ @Test
+ fun test_fallback_to_shortcut_manager() {
+ val componentName = ComponentName("pkg", "Class")
+ val appTarget = mock<DisplayResolveInfo> {
+ whenever(resolvedComponentName).thenReturn(componentName)
+ }
+ val appTargets = arrayOf(appTarget)
+ val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1)
+ val shortcutManagerResult = listOf(
+ ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName),
+ // mismatching shortcut
+ createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1)
+ )
+ val shortcutManager = mock<ShortcutManager> {
+ whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult)
+ }
+ whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager)
+ val testSubject = ShortcutLoader(
+ context,
+ appPredictor,
+ UserHandle.of(0),
+ true,
+ intentFilter,
+ executor,
+ executor,
+ callback
+ )
+
+ testSubject.queryShortcuts(appTargets)
+
+ verify(appPredictor, times(1)).requestPredictionUpdate()
+ val appPredictorCallbackCaptor = ArgumentCaptor.forClass(AppPredictor.Callback::class.java)
+ verify(appPredictor, times(1))
+ .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture())
+ appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList())
+
+ val resultCaptor = ArgumentCaptor.forClass(ShortcutLoader.Result::class.java)
+ verify(callback, times(1)).accept(resultCaptor.capture())
+
+ val result = resultCaptor.value
+ assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor)
+ assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets)
+ assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size)
+ assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget)
+ for (shortcut in result.shortcutsByApp[0].shortcuts) {
+ assertTrue(
+ "AppTargets are not expected the cache of a ShortcutManager result",
+ result.directShareAppTargetCache.isEmpty()
+ )
+ assertEquals(
+ "Wrong ShortcutInfo in the cache",
+ matchingShortcutInfo,
+ result.directShareShortcutInfoCache[shortcut]
+ )
+ }
+ }
+
+ @Test
+ fun test_do_not_call_services_for_not_running_work_profile() {
+ testDisabledWorkProfileDoNotCallSystem(isUserRunning = false)
+ }
+
+ @Test
+ fun test_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() {
+ testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true)
+ }
+
+ @Test
+ fun test_call_services_for_not_running_main_profile() {
+ testAlwaysCallSystemForMainProfile(isUserRunning = false)
+ }
+
+ @Test
+ fun test_call_services_for_locked_main_profile() {
+ testAlwaysCallSystemForMainProfile(isUserUnlocked = false)
+ }
+
+ @Test
+ fun test_call_services_if_quite_mode_is_enabled_for_main_profile() {
+ testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true)
+ }
+
+ private fun testDisabledWorkProfileDoNotCallSystem(
+ isUserRunning: Boolean = true,
+ isUserUnlocked: Boolean = true,
+ isQuietModeEnabled: Boolean = false
+ ) {
+ val userHandle = UserHandle.of(10)
+ val userManager = mock<UserManager> {
+ whenever(isUserRunning(userHandle)).thenReturn(isUserRunning)
+ whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked)
+ whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled)
+ }
+ whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager);
+ val appPredictor = mock<ShortcutLoader.AppPredictorProxy>()
+ val callback = mock<Consumer<ShortcutLoader.Result>>()
+ val testSubject = ShortcutLoader(
+ context,
+ appPredictor,
+ userHandle,
+ false,
+ intentFilter,
+ executor,
+ executor,
+ callback
+ )
+
+ testSubject.queryShortcuts(arrayOf<DisplayResolveInfo>(mock()))
+
+ verify(appPredictor, never()).requestPredictionUpdate()
+ }
+
+ private fun testAlwaysCallSystemForMainProfile(
+ isUserRunning: Boolean = true,
+ isUserUnlocked: Boolean = true,
+ isQuietModeEnabled: Boolean = false
+ ) {
+ val userHandle = UserHandle.of(10)
+ val userManager = mock<UserManager> {
+ whenever(isUserRunning(userHandle)).thenReturn(isUserRunning)
+ whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked)
+ whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled)
+ }
+ whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager);
+ val appPredictor = mock<ShortcutLoader.AppPredictorProxy>()
+ val callback = mock<Consumer<ShortcutLoader.Result>>()
+ val testSubject = ShortcutLoader(
+ context,
+ appPredictor,
+ userHandle,
+ true,
+ intentFilter,
+ executor,
+ executor,
+ callback
+ )
+
+ testSubject.queryShortcuts(arrayOf<DisplayResolveInfo>(mock()))
+
+ verify(appPredictor, times(1)).requestPredictionUpdate()
+ }
+}
+
+private class ImmediateExecutor : Executor {
+ override fun execute(r: Runnable) {
+ r.run()
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt
new file mode 100644
index 0000000..e0de005
--- /dev/null
+++ b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt
@@ -0,0 +1,177 @@
+/*
+ * 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.prediction.AppTarget
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+import android.content.pm.ShortcutManager.ShareShortcutInfo
+import android.service.chooser.ChooserTarget
+import com.android.intentresolver.createAppTarget
+import com.android.intentresolver.createShareShortcutInfo
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+private const val PACKAGE = "org.package"
+
+class ShortcutToChooserTargetConverterTest {
+ private val testSubject = ShortcutToChooserTargetConverter()
+ private val ranks = arrayOf(3 ,7, 1 ,3)
+ private val shortcuts = ranks
+ .foldIndexed(ArrayList<ShareShortcutInfo>(ranks.size)) { i, acc, rank ->
+ val id = i + 1
+ acc.add(
+ createShareShortcutInfo(
+ id = "id-$i",
+ componentName = ComponentName(PACKAGE, "Class$id"),
+ rank,
+ )
+ )
+ acc
+ }
+
+ @Test
+ fun testConvertToChooserTarget_predictionService() {
+ val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) }
+ val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3)
+ val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f)
+ val appTargetCache = HashMap<ChooserTarget, AppTarget>()
+ val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>()
+
+ var chooserTargets = testSubject.convertToChooserTarget(
+ shortcuts,
+ shortcuts,
+ appTargets,
+ appTargetCache,
+ shortcutInfoCache,
+ )
+
+ assertCorrectShortcutToChooserTargetConversion(
+ shortcuts,
+ chooserTargets,
+ expectedOrderAllShortcuts,
+ expectedScoreAllShortcuts,
+ )
+ assertAppTargetCache(chooserTargets, appTargetCache)
+ assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
+
+ val subset = shortcuts.subList(1, shortcuts.size)
+ val expectedOrderSubset = intArrayOf(1, 2, 3)
+ val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f)
+ appTargetCache.clear()
+ shortcutInfoCache.clear()
+
+ chooserTargets = testSubject.convertToChooserTarget(
+ subset,
+ shortcuts,
+ appTargets,
+ appTargetCache,
+ shortcutInfoCache,
+ )
+
+ assertCorrectShortcutToChooserTargetConversion(
+ shortcuts,
+ chooserTargets,
+ expectedOrderSubset,
+ expectedScoreSubset,
+ )
+ assertAppTargetCache(chooserTargets, appTargetCache)
+ assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
+ }
+
+ @Test
+ fun testConvertToChooserTarget_shortcutManager() {
+ val testSubject = ShortcutToChooserTargetConverter()
+ val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1)
+ val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f)
+ val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>()
+
+ var chooserTargets = testSubject.convertToChooserTarget(
+ shortcuts,
+ shortcuts,
+ null,
+ null,
+ shortcutInfoCache,
+ )
+
+ assertCorrectShortcutToChooserTargetConversion(
+ shortcuts, chooserTargets,
+ expectedOrderAllShortcuts, expectedScoreAllShortcuts
+ )
+ assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
+
+ val subset: MutableList<ShareShortcutInfo> = java.util.ArrayList()
+ subset.add(shortcuts[1])
+ subset.add(shortcuts[2])
+ subset.add(shortcuts[3])
+ val expectedOrderSubset = intArrayOf(2, 3, 1)
+ val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f)
+ shortcutInfoCache.clear()
+
+ chooserTargets = testSubject.convertToChooserTarget(
+ subset,
+ shortcuts,
+ null,
+ null,
+ shortcutInfoCache,
+ )
+
+ assertCorrectShortcutToChooserTargetConversion(
+ shortcuts, chooserTargets,
+ expectedOrderSubset, expectedScoreSubset
+ )
+ assertShortcutInfoCache(chooserTargets, shortcutInfoCache)
+ }
+
+ private fun assertCorrectShortcutToChooserTargetConversion(
+ shortcuts: List<ShareShortcutInfo>,
+ chooserTargets: List<ChooserTarget>,
+ expectedOrder: IntArray,
+ expectedScores: FloatArray,
+ ) {
+ assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size)
+ for (i in chooserTargets.indices) {
+ val ct = chooserTargets[i]
+ val si = shortcuts[expectedOrder[i]].shortcutInfo
+ val cn = shortcuts[expectedOrder[i]].targetComponent
+ assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID))
+ assertEquals(si.label, ct.title)
+ assertEquals(expectedScores[i], ct.score)
+ assertEquals(cn, ct.componentName)
+ }
+ }
+
+ private fun assertAppTargetCache(
+ chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, AppTarget>
+ ) {
+ for (ct in chooserTargets) {
+ val target = cache[ct]
+ assertNotNull("AppTarget is missing", target)
+ }
+ }
+
+ private fun assertShortcutInfoCache(
+ chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, ShortcutInfo>
+ ) {
+ for (ct in chooserTargets) {
+ val si = cache[ct]
+ assertNotNull("AppTarget is missing", si)
+ }
+ }
+}