diff options
444 files changed, 16115 insertions, 23646 deletions
@@ -34,6 +34,7 @@ java_defaults { strict_updatability_linting: false, extra_check_modules: ["SystemUILintChecker"], warning_checks: ["MissingApacheLicenseDetector"], + baseline_filename: "lint-baseline.xml", }, } @@ -69,6 +70,11 @@ android_library { "androidx.lifecycle_lifecycle-viewmodel-compose", "androidx.lifecycle_lifecycle-runtime-compose", ], + javacflags: [ + "-Adagger.fastInit=enabled", + "-Adagger.explicitBindingConflictsWithInject=ERROR", + "-Adagger.strictMultibindingValidation=enabled", + ], } java_defaults { diff --git a/AndroidManifest-app.xml b/AndroidManifest-app.xml index ec4fec85..7338dd08 100644 --- a/AndroidManifest-app.xml +++ b/AndroidManifest-app.xml @@ -32,43 +32,8 @@ android:requiredForAllUsers="true" android:supportsRtl="true"> - <!-- This alias needs to be maintained until there are no more devices that could be - upgrading from T QPR3. (b/283722356) --> - <activity-alias - android:name=".ChooserActivityLauncher" - android:targetActivity=".ChooserActivity" - android:exported="true"> - - <!-- This intent filter is assigned a priority greater than 100 so - that it will take precedence over the framework ChooserActivity - in the process of resolving implicit action.CHOOSER intents - whenever this activity is enabled by the experiment flag. --> - <intent-filter android:priority="500"> - <action android:name="android.intent.action.CHOOSER" /> - <category android:name="android.intent.category.DEFAULT" /> - <category android:name="android.intent.category.VOICE" /> - </intent-filter> - </activity-alias> - <activity android:name=".ChooserActivity" - android:theme="@style/Theme.DeviceDefault.Chooser" - android:finishOnCloseSystemDialogs="true" - android:excludeFromRecents="true" - android:documentLaunchMode="never" - android:relinquishTaskIdentity="true" - android:configChanges="screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden" - android:visibleToInstantApps="true" - android:exported="false"/> - - <receiver android:name="com.android.intentresolver.v2.ChooserSelector" - android:exported="true"> - <intent-filter> - <action android:name="android.intent.action.BOOT_COMPLETED" /> - </intent-filter> - </receiver> - - <activity android:name="com.android.intentresolver.v2.ChooserActivity" - android:enabled="false" + android:enabled="true" android:theme="@style/Theme.DeviceDefault.Chooser" android:finishOnCloseSystemDialogs="true" android:excludeFromRecents="true" @@ -76,19 +41,22 @@ android:relinquishTaskIdentity="true" android:configChanges="screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden" android:visibleToInstantApps="true" + android:exported="false" /> + + <!-- This alias needs to be maintained until there are no more devices that could be + upgrading from T QPR3. (b/283722356) --> + <activity-alias + android:name=".ChooserActivityLauncher" + android:targetActivity=".ChooserActivity" android:exported="true"> - <!-- This intent filter is assigned a priority greater than 500 so - that it will take precedence over the ChooserActivity - in the process of resolving implicit action.CHOOSER intents - whenever this activity is enabled by the experiment flag. --> - <intent-filter android:priority="501"> + <intent-filter android:priority="500"> <action android:name="android.intent.action.CHOOSER" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.VOICE" /> </intent-filter> - </activity> + </activity-alias> <provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 04883baf..a102328a 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -16,13 +16,6 @@ flag { } flag { - name: "scrollable_preview" - namespace: "intentresolver" - description: "Makes preview scrollable with multiple profiles" - bug: "287102904" -} - -flag { name: "target_data_caching" namespace: "intentresolver" description: "Enables caching target icons and labels in a local DB" @@ -47,5 +40,42 @@ flag { name: "enable_private_profile" namespace: "intentresolver" description: "Enable private profile support" - bug: "311348033" + bug: "328029692" +} + +flag { + name: "refine_system_actions" + namespace: "intentresolver" + description: "This flag enables sending system actions to the caller refinement flow" + bug: "331206205" + metadata { + purpose: PURPOSE_BUGFIX + } +} + +flag { + name: "fix_empty_state_padding" + namespace: "intentresolver" + description: "Always apply systemBar window insets regardless of profiles present" + bug: "338447666" +} + +flag { + name: "fix_empty_state_padding_bug" + namespace: "intentresolver" + description: "Always apply systemBar window insets regardless of profiles present" + bug: "338447666" + metadata { + purpose: PURPOSE_BUGFIX + } +} + +flag { + name: "fix_private_space_locked_on_restart" + namespace: "intentresolver" + description: "Dismiss Share sheet on restart if private space became locked while stopped" + bug: "338125945" + metadata { + purpose: PURPOSE_BUGFIX + } } diff --git a/java/res/drawable/resolver_profile_tab_bg.xml b/java/res/drawable/resolver_profile_tab_bg.xml index 8bb23a53..97f3b7e2 100644 --- a/java/res/drawable/resolver_profile_tab_bg.xml +++ b/java/res/drawable/resolver_profile_tab_bg.xml @@ -25,7 +25,7 @@ </item> <item> - <selector android:enterFadeDuration="100"> + <selector> <item android:state_selected="false"> <shape android:shape="rectangle"> <corners android:radius="12dp" /> diff --git a/java/res/layout/chooser_grid.xml b/java/res/layout/chooser_grid.xml deleted file mode 100644 index 8320b284..00000000 --- a/java/res/layout/chooser_grid.xml +++ /dev/null @@ -1,97 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- -/* -* Copyright 2015, The Android Open Source Project -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -*/ ---> -<com.android.intentresolver.widget.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" - app:maxCollapsedHeight="0dp" - app:maxCollapsedHeightSmall="56dp" - android:maxWidth="@dimen/chooser_width" - android:id="@androidprv:id/contentPanel"> - - <RelativeLayout - android:id="@androidprv:id/chooser_header" - android:layout_width="match_parent" - android:layout_height="wrap_content" - app:layout_alwaysShow="true" - android:elevation="0dp" - android:background="@drawable/bottomsheet_background"> - - <View - android:id="@androidprv:id/drag" - android:layout_width="64dp" - android:layout_height="4dp" - android:background="@drawable/ic_drag_handle" - android:layout_marginTop="@dimen/chooser_edge_margin_thin" - android:layout_marginBottom="@dimen/chooser_edge_margin_thin" - android:layout_centerHorizontal="true" - android:layout_alignParentTop="true" /> - - <TextView android:id="@android:id/title" - android:layout_height="wrap_content" - android:layout_width="wrap_content" - android:textAppearance="@android:style/TextAppearance.DeviceDefault.WindowTitle" - android:gravity="center" - android:paddingBottom="@dimen/chooser_view_spacing" - android:paddingLeft="24dp" - android:paddingRight="24dp" - android:visibility="gone" - android:layout_below="@androidprv:id/drag" - android:layout_centerHorizontal="true"/> - </RelativeLayout> - - <FrameLayout - android:id="@androidprv:id/content_preview_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:visibility="gone" /> - - <TabHost - android:id="@androidprv:id/profile_tabhost" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:layout_centerHorizontal="true" - android:background="?androidprv:attr/materialColorSurfaceContainer"> - <LinearLayout - android:orientation="vertical" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - <TabWidget - android:id="@android:id/tabs" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:visibility="gone"> - </TabWidget> - <FrameLayout - android:id="@android:id/tabcontent" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - <com.android.intentresolver.ResolverViewPager - android:id="@androidprv:id/profile_pager" - android:layout_width="match_parent" - android:layout_height="wrap_content"/> - </FrameLayout> - </LinearLayout> - </TabHost> - -</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 90832d23..4e8cf7ba 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -26,14 +26,6 @@ android:orientation="vertical" android:background="?androidprv:attr/materialColorSurfaceContainer"> - <ViewStub - android:id="@+id/chooser_headline_row_stub" - android:layout="@layout/chooser_headline_row" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="@dimen/chooser_edge_margin_normal" - android:layout_marginBottom="@dimen/chooser_view_spacing" /> - <RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" diff --git a/java/res/layout/chooser_grid_preview_files_text.xml b/java/res/layout/chooser_grid_preview_files_text.xml index e7747496..65c62f82 100644 --- a/java/res/layout/chooser_grid_preview_files_text.xml +++ b/java/res/layout/chooser_grid_preview_files_text.xml @@ -25,14 +25,6 @@ android:orientation="vertical" android:background="?androidprv:attr/materialColorSurfaceContainer"> - <ViewStub - android:id="@+id/chooser_headline_row_stub" - android:layout="@layout/chooser_headline_row" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="@dimen/chooser_edge_margin_normal" - android:layout_marginBottom="@dimen/chooser_view_spacing" /> - <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" @@ -61,6 +53,7 @@ android:maxLines="@integer/text_preview_lines" android:ellipsize="end" android:linksClickable="false" + android:textColor="?androidprv:attr/materialColorOnSurfaceVariant" android:textAppearance="@style/TextAppearance.ChooserDefault"/> </LinearLayout> diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index f3045c34..ee54c0ae 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -27,14 +27,6 @@ android:orientation="vertical" android:background="?androidprv:attr/materialColorSurfaceContainer"> - <ViewStub - android:id="@+id/chooser_headline_row_stub" - android:layout="@layout/chooser_headline_row" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="@dimen/chooser_edge_margin_normal" - android:layout_marginBottom="@dimen/chooser_view_spacing" /> - <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" @@ -122,4 +114,3 @@ <include layout="@layout/chooser_action_row" /> </LinearLayout> - diff --git a/java/res/layout/chooser_headline_row.xml b/java/res/layout/chooser_headline_row.xml index 97e8552e..bfce7473 100644 --- a/java/res/layout/chooser_headline_row.xml +++ b/java/res/layout/chooser_headline_row.xml @@ -65,13 +65,17 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:maxWidth="@dimen/modify_share_text_toggle_max_width" + android:background="@drawable/chooser_action_button_bg" app:layout_constraintEnd_toEndOf="parent" android:maxLines="2" android:ellipsize="end" android:visibility="gone" - android:paddingTop="3dp" - style="@style/TextAppearance.ChooserDefault" + android:paddingVertical="3dp" + android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half" + style="?android:attr/borderlessButtonStyle" android:drawableEnd="@drawable/chevron_right" + android:textColor="?androidprv:attr/materialColorOnSurface" + android:textSize="12sp" /> <!-- This is only relevant for image+text preview, but needs to be in this layout so it can diff --git a/java/res/layout/chooser_list_per_profile.xml b/java/res/layout/chooser_list_per_profile.xml deleted file mode 100644 index ef82090c..00000000 --- a/java/res/layout/chooser_list_per_profile.xml +++ /dev/null @@ -1,34 +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. - --> -<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"> - <androidx.recyclerview.widget.RecyclerView - android:layout_width="match_parent" - android:layout_height="match_parent" - app:layoutManager="com.android.intentresolver.ChooserGridLayoutManager" - android:id="@androidprv:id/resolver_list" - android:clipToPadding="false" - android:background="?androidprv:attr/materialColorSurfaceContainer" - android:scrollbars="none" - android:elevation="1dp" - android:nestedScrollingEnabled="true" /> - - <include layout="@layout/resolver_empty_states" /> -</RelativeLayout> diff --git a/java/res/layout/resolver_empty_states.xml b/java/res/layout/resolver_empty_states.xml index d77630ee..0cf6e955 100644 --- a/java/res/layout/resolver_empty_states.xml +++ b/java/res/layout/resolver_empty_states.xml @@ -79,13 +79,13 @@ android:layout_centerHorizontal="true" android:layout_below="@androidprv:id/resolver_empty_state_subtitle" android:indeterminateTint="?android:attr/colorAccent"/> + <TextView + android:id="@android:id/empty" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/noApplications" + android:textColor="?androidprv:attr/materialColorOnSurfaceVariant" + android:padding="@dimen/chooser_edge_margin_normal" + android:gravity="center"/> </RelativeLayout> - <TextView android:id="@android:id/empty" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="?android:attr/colorBackground" - android:text="@string/noApplications" - android:padding="@dimen/chooser_edge_margin_normal" - android:layout_marginBottom="56dp" - android:gravity="center"/> </RelativeLayout> diff --git a/java/res/values-af/strings.xml b/java/res/values-af/strings.xml index 4ce4e614..52063f5b 100644 --- a/java/res/values-af/strings.xml +++ b/java/res/values-af/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Hierdie inhoud kan nie met werkprogramme oopgemaak word nie"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Hierdie inhoud kan nie met persoonlike apps gedeel word nie"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Hierdie inhoud kan nie met persoonlike programme oopgemaak word nie"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Hierdie inhoud kan nie met privaat apps gedeel word nie"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Hierdie inhoud kan nie met privaat apps oopgemaak word nie"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Werkapps word onderbreek"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Hervat"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Geen werkprogramme nie"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Geen persoonlike programme nie"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Geen private apps nie"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Maak <xliff:g id="APP">%s</xliff:g> in jou persoonlike profiel oop?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Maak <xliff:g id="APP">%s</xliff:g> in jou werkprofiel oop?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gebruik persoonlike blaaier"</string> diff --git a/java/res/values-am/strings.xml b/java/res/values-am/strings.xml index e8c5a033..d1d581a2 100644 --- a/java/res/values-am/strings.xml +++ b/java/res/values-am/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ይህ ይዘት በሥራ መተግበሪያዎች መከፈት አይችልም"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ይህ ይዘት በግል መተግበሪያዎች መጋራት አይችልም"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ይህ ይዘት በግል መተግበሪያዎች መከፈት አይችልም"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ይህ ይዘት በግል መተግበሪያዎች ሊጋራ አይችልም"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ይህ ይዘት በግል መተግበሪያዎች ሊከፈት አይችልም"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"የሥራ መተግበሪያዎች ባሉበት ቆመዋል"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ከቆመበት ቀጥል"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ምንም የሥራ መተግበሪያዎች የሉም"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ምንም የግል መተግበሪያዎች የሉም"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ምንም የግል መተግበሪያዎች የሉም"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> በግል መገለጫዎ ውስጥ ይከፈት?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> በስራ መገለጫዎ ውስጥ ይከፈት?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"የግል አሳሽ ተጠቀም"</string> diff --git a/java/res/values-ar/strings.xml b/java/res/values-ar/strings.xml index e64af7e6..76e74b83 100644 --- a/java/res/values-ar/strings.xml +++ b/java/res/values-ar/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"لا يمكن فتح هذا المحتوى باستخدام تطبيقات العمل."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"لا يمكن مشاركة هذا المحتوى مع التطبيقات الشخصية."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"لا يمكن فتح هذا المحتوى باستخدام التطبيقات الشخصية."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"لا يمكن مشاركة هذا المحتوى مع التطبيقات الخاصة"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"لا يمكن فتح هذا المحتوى باستخدام التطبيقات الخاصة"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"تطبيقات العمل متوقفة مؤقتًا."</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"إلغاء الإيقاف المؤقت"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ما مِن تطبيقات عمل."</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ما مِن تطبيقات شخصية."</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ما مِن تطبيقات خاصة"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"هل تريد فتح <xliff:g id="APP">%s</xliff:g> في ملفك الشخصي؟"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"هل تريد فتح <xliff:g id="APP">%s</xliff:g> في ملفك الشخصي للعمل؟"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"استخدام المتصفّح الشخصي"</string> diff --git a/java/res/values-as/strings.xml b/java/res/values-as/strings.xml index dd677968..c732113a 100644 --- a/java/res/values-as/strings.xml +++ b/java/res/values-as/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"এই সমল কৰ্মস্থানৰ এপৰ জৰিয়তে খুলিব নোৱাৰি"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"এই সমল ব্যক্তিগত এপৰ সৈতে শ্বেয়াৰ কৰিব নোৱাৰি"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"এই সমল ব্যক্তিগত এপৰ জৰিয়তে খুলিব নোৱাৰি"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"এই সমল ব্যক্তিগত এপৰ সৈতে শ্বেয়াৰ কৰিব নোৱাৰি"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"এই সমল ব্যক্তিগত এপৰ জৰিয়তে খুলিব নোৱাৰি"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"কাম সম্পর্কীয় এপ্ পজ কৰা আছে"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"আনপজ কৰক"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"কোনো কৰ্মস্থানৰ এপ্ নাই"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"কোনো ব্যক্তিগত এপ্ নাই"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"কোনো ব্যক্তিগত এপ্নাই"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"আপোনাৰ ব্যক্তিগত প্ৰ’ফাইলত <xliff:g id="APP">%s</xliff:g> খুলিবনে?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"আপোনাৰ কর্মস্থানৰ প্ৰ\'ফাইলত <xliff:g id="APP">%s</xliff:g> খুলিবনে?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ব্যক্তিগত ব্ৰাউজাৰ ব্যৱহাৰ কৰক"</string> diff --git a/java/res/values-az/strings.xml b/java/res/values-az/strings.xml index e6d8a306..d5d6b75f 100644 --- a/java/res/values-az/strings.xml +++ b/java/res/values-az/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu kontenti iş tətbiqləri ilə açmaq mümkün deyil"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bu kontenti şəxsi tətbiqlər ilə paylaşmaq mümkün deyil"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bu kontenti şəxsi tətbiqlər ilə açmaq mümkün deyil"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Bu kontenti şəxsi tətbiqlərlə paylaşmaq mümkün deyil"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Bu kontenti şəxsi tətbiqlərlə açmaq mümkün deyil"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"İş tətbiqləri durdurulub"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Pauzanı bitirin"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"İş tətbiqi yoxdur"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Şəxsi tətbiq yoxdur"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Şəxsi tətbiq yoxdur"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Şəxsi profilinizdə <xliff:g id="APP">%s</xliff:g> tətbiqi açılsın?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"İş profilinizdə <xliff:g id="APP">%s</xliff:g> tətbiqi açılsın?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Şəxsi brauzerdən istifadə edin"</string> diff --git a/java/res/values-b+sr+Latn/strings.xml b/java/res/values-b+sr+Latn/strings.xml index 2ae6b364..99339496 100644 --- a/java/res/values-b+sr+Latn/strings.xml +++ b/java/res/values-b+sr+Latn/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ovaj sadržaj ne može da se otvara pomoću poslovnih aplikacija"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ovaj sadržaj ne može da se deli pomoću ličnih aplikacija"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ovaj sadržaj ne može da se otvara pomoću ličnih aplikacija"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Ovaj sadržaj ne može da se deli pomoću privatnih aplikacija"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Ovaj sadržaj ne može da se otvori pomoću privatnih aplikacija"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Poslovne aplikacije su pauzirane"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Ponovo aktiviraj"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nema poslovnih aplikacija"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nema ličnih aplikacija"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Bez privatnih aplikacija"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Želite da na ličnom profilu otvorite: <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Želite da na poslovnom profilu otvorite: <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Koristi lični pregledač"</string> diff --git a/java/res/values-be/strings.xml b/java/res/values-be/strings.xml index 2f5d8004..6f9190bc 100644 --- a/java/res/values-be/strings.xml +++ b/java/res/values-be/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Не ўдалося адкрыць гэта змесціва з дапамогай працоўных праграм"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Не ўдалося абагуліць гэта змесціва з асабістымі праграмамі"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Не ўдалося адкрыць гэта змесціва з дапамогай асабістых праграм"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Не ўдалося абагуліць гэта змесціва з прыватнымі праграмамі"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Не ўдалося адкрыць гэта змесціва з дапамогай прыватных праграм"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Працоўныя праграмы прыпынены"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Уключыць"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Няма працоўных праграм"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Няма асабістых праграм"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Даступных прыватных праграм няма"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Адкрыць праграму \"<xliff:g id="APP">%s</xliff:g>\" з выкарыстаннем асабістага профілю?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Адкрыць праграму \"<xliff:g id="APP">%s</xliff:g>\" з выкарыстаннем працоўнага профілю?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Скарыстаць асабісты браўзер"</string> diff --git a/java/res/values-bg/strings.xml b/java/res/values-bg/strings.xml index 2736cb13..f16e5486 100644 --- a/java/res/values-bg/strings.xml +++ b/java/res/values-bg/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Това съдържание не може да се отваря със служебни приложения"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Това съдържание не може да се споделя с лични приложения"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Това съдържание не може да се отваря с лични приложения"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Това съдържание не може да се споделя с частни приложения"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Това съдържание не може да се отваря с частни приложения"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Служебните приложения са поставени на пауза"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Отмяна на паузата"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Няма подходящи служебни приложения"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Няма подходящи лични приложения"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Няма частни приложения"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Искате ли да отворите <xliff:g id="APP">%s</xliff:g> в личния си потребителски профил?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Искате ли да отворите <xliff:g id="APP">%s</xliff:g> в служебния си потребителски профил?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Използване на личния браузър"</string> diff --git a/java/res/values-bn/strings.xml b/java/res/values-bn/strings.xml index 4d4d70d1..41778ade 100644 --- a/java/res/values-bn/strings.xml +++ b/java/res/values-bn/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"অফিসের অ্যাপে এই খোলা যাবে না"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ব্যক্তিগত অ্যাপে এই কন্টেন্ট শেয়ার করা যাবে না"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ব্যক্তিগত অ্যাপে এই কন্টেন্ট খোলা যাবে না"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"এই কন্টেন্ট ব্যক্তিগত অ্যাপে শেয়ার করা যাবে না"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"এই কন্টেন্ট ব্যক্তিগত অ্যাপে খোলা যাবে না"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"অফিসের অ্যাপ পজ করা আছে"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"আনপজ করুন"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"এর জন্য কোনও অফিস অ্যাপ নেই"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ব্যক্তিগত অ্যাপে দেখা যাবে না"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"কোনও ব্যক্তিগত অ্যাপ নেই"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"আপনার ব্যক্তিগত প্রোফাইল থেকে <xliff:g id="APP">%s</xliff:g> খুলবেন?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"আপনার অফিস প্রোফাইল থেকে <xliff:g id="APP">%s</xliff:g> খুলবেন?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ব্যক্তিগত ব্রাউজার ব্যবহার করুন"</string> diff --git a/java/res/values-bs/strings.xml b/java/res/values-bs/strings.xml index 0eb09838..d8cfc346 100644 --- a/java/res/values-bs/strings.xml +++ b/java/res/values-bs/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{i još # fajl}one{i još # fajl}few{i još # fajla}other{i još # fajlova}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Dijeljenje teksta"</string> <string name="sharing_link" msgid="2307694372813942916">"Dijeljenje linka"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Dijeli se slika}one{Dijeli se # slika}few{Dijele se # slike}other{Dijeli se # slika}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Dijeljenje slike}one{Dijeljenje # slike}few{Dijeljenje # slike}other{Dijeljenje # slika}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Dijeljenje videozapisa}one{Dijeljenje # videozapisa}few{Dijeljenje # videozapisa}other{Dijeljenje # videozapisa}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Dijeljenje # fajla}one{Dijeljenje # fajla}few{Dijeljenje # fajla}other{Dijeljenje # fajlova}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Dijeljenje slike putem poruke}one{Dijeljenje # slike putem poruke}few{Dijeljenje # slike putem poruke}other{Dijeljenje # slika putem poruke}}"</string> @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ovaj sadržaj nije moguće otvoriti pomoću poslovnih aplikacija"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ovaj sadržaj nije moguće dijeliti pomoću ličnih aplikacija"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ovaj sadržaj nije moguće otvoriti pomoću ličnih aplikacija"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Sadržaj se ne može dijeliti pomoću privatnih aplikacija"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Sadržaj se ne može otvoriti pomoću privatnih aplikacija"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Poslovne aplikacije su pauzirane"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Ponovo pokreni"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nema poslovnih aplikacija"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nema ličnih aplikacija"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nema privatnih aplikacija"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na ličnom profilu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na radnom profilu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Koristi lični preglednik"</string> diff --git a/java/res/values-ca/strings.xml b/java/res/values-ca/strings.xml index a3407873..d3af1d2b 100644 --- a/java/res/values-ca/strings.xml +++ b/java/res/values-ca/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"No es pot obrir aquest contingut amb aplicacions de treball"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"No es pot compartir aquest contingut amb aplicacions personals"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"No es pot obrir aquest contingut amb aplicacions personals"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Aquest contingut no es pot compartir amb aplicacions privades"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Aquest contingut no es pot obrir amb aplicacions privades"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les aplicacions de treball estan en pausa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactiva"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Cap aplicació de treball"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Cap aplicació personal"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Cap aplicació privada"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vols obrir <xliff:g id="APP">%s</xliff:g> al teu perfil personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vols obrir <xliff:g id="APP">%s</xliff:g> al teu perfil de treball?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utilitza el navegador personal"</string> diff --git a/java/res/values-cs/strings.xml b/java/res/values-cs/strings.xml index 93712487..8c3d6f88 100644 --- a/java/res/values-cs/strings.xml +++ b/java/res/values-cs/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tento obsah nelze otevřít pomocí pracovních aplikací"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tento obsah nelze sdílet pomocí osobních aplikací"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tento obsah nelze otevřít pomocí osobních aplikací"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tento obsah nelze sdílet se soukromými aplikacemi"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tento obsah nelze otevřít pomocí soukromých aplikací"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Pracovní aplikace jsou pozastaveny"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Zrušit pozastavení"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Žádné pracovní aplikace"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Žádné osobní aplikace"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Žádné soukromé aplikace"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Otevřít aplikaci <xliff:g id="APP">%s</xliff:g> v osobním profilu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Otevřít aplikaci <xliff:g id="APP">%s</xliff:g> v pracovním profilu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Použít osobní prohlížeč"</string> diff --git a/java/res/values-da/strings.xml b/java/res/values-da/strings.xml index 26385908..cc726342 100644 --- a/java/res/values-da/strings.xml +++ b/java/res/values-da/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Dette indhold kan ikke åbnes med arbejdsapps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Dette indhold kan ikke deles med personlige apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Dette indhold kan ikke åbnes med personlige apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Dette indhold kan ikke deles med private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Dette indhold kan ikke åbnes med private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Dine arbejdsapps er sat på pause"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Genoptag"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Der er ingen arbejdsapps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Der er ingen personlige apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ingen private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vil du åbne <xliff:g id="APP">%s</xliff:g> på din personlige profil?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vil du åbne <xliff:g id="APP">%s</xliff:g> på din arbejdsprofil?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Brug personlig browser"</string> diff --git a/java/res/values-de/strings.xml b/java/res/values-de/strings.xml index f65618e7..32c8265f 100644 --- a/java/res/values-de/strings.xml +++ b/java/res/values-de/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Diese Art von Inhalt kann nicht mit geschäftlichen Apps geöffnet werden"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Diese Art von Inhalt kann nicht über private Apps geteilt werden"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Diese Art von Inhalt kann nicht mit privaten Apps geöffnet werden"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Diese Art von Inhalt kann nicht über interne Apps geteilt werden"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Diese Art von Inhalt kann nicht mit internen Apps geöffnet werden"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Geschäftliche Apps sind pausiert"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Nicht mehr pausieren"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Keine geschäftlichen Apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Keine privaten Apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Keine internen Apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> in deinem privaten Profil öffnen?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> in deinem Arbeitsprofil öffnen?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Privaten Browser verwenden"</string> diff --git a/java/res/values-el/strings.xml b/java/res/values-el/strings.xml index f88dc751..5e02ab9e 100644 --- a/java/res/values-el/strings.xml +++ b/java/res/values-el/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με εφαρμογές εργασιών"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Δεν είναι δυνατή η κοινοποίηση αυτού του περιεχομένου με προσωπικές εφαρμογές"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με προσωπικές εφαρμογές"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Δεν είναι δυνατή η κοινοποίηση αυτού του περιεχομένου σε ιδιωτικές εφαρμογές"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με ιδιωτικές εφαρμογές"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Οι εφαρμογές εργασιών τέθηκαν σε παύση"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Αναίρεση παύσης"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Δεν υπάρχουν εφαρμογές εργασιών"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Δεν υπάρχουν προσωπικές εφαρμογές"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Καμία ιδιωτική εφαρμογή"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Θέλετε να ανοίξετε την εφαρμογή <xliff:g id="APP">%s</xliff:g> στο προσωπικό σας προφίλ;"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Θέλετε να ανοίξετε την εφαρμογή <xliff:g id="APP">%s</xliff:g> στο προφίλ σας εργασίας;"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Χρήση προσωπικού προγράμματος περιήγησης"</string> diff --git a/java/res/values-en-rAU/strings.xml b/java/res/values-en-rAU/strings.xml index 90f6974a..28067f40 100644 --- a/java/res/values-en-rAU/strings.xml +++ b/java/res/values-en-rAU/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can\'t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can\'t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> diff --git a/java/res/values-en-rCA/strings.xml b/java/res/values-en-rCA/strings.xml index 90f6974a..de4865f0 100644 --- a/java/res/values-en-rCA/strings.xml +++ b/java/res/values-en-rCA/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can’t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can’t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> diff --git a/java/res/values-en-rGB/strings.xml b/java/res/values-en-rGB/strings.xml index 90f6974a..28067f40 100644 --- a/java/res/values-en-rGB/strings.xml +++ b/java/res/values-en-rGB/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can\'t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can\'t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> diff --git a/java/res/values-en-rIN/strings.xml b/java/res/values-en-rIN/strings.xml index 90f6974a..28067f40 100644 --- a/java/res/values-en-rIN/strings.xml +++ b/java/res/values-en-rIN/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can\'t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can\'t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> diff --git a/java/res/values-en-rXC/strings.xml b/java/res/values-en-rXC/strings.xml index f0650785..f02e2c0d 100644 --- a/java/res/values-en-rXC/strings.xml +++ b/java/res/values-en-rXC/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can’t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can’t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> diff --git a/java/res/values-es-rUS/strings.xml b/java/res/values-es-rUS/strings.xml index e79f383b..a881ede8 100644 --- a/java/res/values-es-rUS/strings.xml +++ b/java/res/values-es-rUS/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"No se puede abrir este contenido con apps de trabajo"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"No se pueden usar apps personales para compartir este contenido"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"No se puede abrir este contenido con apps personales"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"No se puede compartir este contenido con apps privadas"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"No se puede abrir este contenido con apps privadas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Se pausaron las apps de trabajo"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reanudar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"El contenido no es compatible con apps de trabajo"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"El contenido no es compatible con apps personales"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No hay apps privadas"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"¿Quieres abrir <xliff:g id="APP">%s</xliff:g> en tu perfil personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"¿Quieres abrir <xliff:g id="APP">%s</xliff:g> en tu perfil de trabajo?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar un navegador personal"</string> diff --git a/java/res/values-es/strings.xml b/java/res/values-es/strings.xml index 28b37a73..62063d99 100644 --- a/java/res/values-es/strings.xml +++ b/java/res/values-es/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Este contenido no se puede abrir con aplicaciones de trabajo"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Este contenido no se puede compartir con aplicaciones personales"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Este contenido no se puede abrir con aplicaciones personales"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Este contenido no se puede compartir con aplicaciones privadas"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Este contenido no se puede abrir con aplicaciones privadas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Las aplicaciones de trabajo están en pausa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactivar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ninguna aplicación de trabajo"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ninguna aplicación personal"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No hay aplicaciones privadas"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"¿Abrir <xliff:g id="APP">%s</xliff:g> en tu perfil personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"¿Abrir <xliff:g id="APP">%s</xliff:g> en tu perfil de trabajo?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar navegador personal"</string> diff --git a/java/res/values-et/strings.xml b/java/res/values-et/strings.xml index 9b4fff07..27d14e00 100644 --- a/java/res/values-et/strings.xml +++ b/java/res/values-et/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Seda sisu ei saa töörakendustega avada"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Seda sisu ei saa isiklike rakendustega jagada."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Seda sisu ei saa isiklike rakendustega avada"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Seda sisu ei saa privaatsete rakendustega jagada"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Seda sisu ei saa privaatsete rakendustega avada"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Töörakendused on peatatud"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Jätka"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Töörakendusi pole"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Isiklikke rakendusi pole"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Privaatseid rakendusi pole"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Kas avada <xliff:g id="APP">%s</xliff:g> teie isiklikul profiilil?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Kas avada <xliff:g id="APP">%s</xliff:g> teie tööprofiilil?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Kasuta isiklikku brauserit"</string> diff --git a/java/res/values-eu/strings.xml b/java/res/values-eu/strings.xml index 4ce8be6b..a77d5db2 100644 --- a/java/res/values-eu/strings.xml +++ b/java/res/values-eu/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{eta beste # fitxategi}other{eta beste # fitxategi}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Partekatuko den testua"</string> <string name="sharing_link" msgid="2307694372813942916">"Esteka partekatzen"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Irudia partekatuko da}other{# irudi partekatuko dira}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Irudia partekatzen}other{# irudi partekatzen}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Bideoa partekatzen}other{# bideo partekatzen}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# fitxategi partekatuko da}other{# fitxategi partekatuko dira}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Irudi testudun bat partekatuko da}other{# irudi testudun partekatuko dira}}"</string> @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Eduki hau ezin da laneko aplikazioekin ireki"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Eduki hau ezin da aplikazio pertsonalekin partekatu"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Eduki hau ezin da aplikazio pertsonalekin ireki"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Eduki hau ezin da aplikazio pribatuekin partekatu"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Eduki hau ezin da aplikazio pribatuekin ireki"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Pausatuta daude laneko aplikazioak"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Berraktibatu"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ez dago laneko aplikaziorik"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ez dago aplikazio pertsonalik"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Aplikazio pribaturik ez"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Profil pertsonalean ireki nahi duzu <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Laneko profilean ireki nahi duzu <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Erabili arakatzaile pertsonala"</string> diff --git a/java/res/values-fa/strings.xml b/java/res/values-fa/strings.xml index 7c7e8fe2..55204e51 100644 --- a/java/res/values-fa/strings.xml +++ b/java/res/values-fa/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"نمیتوان این محتوا را با برنامههای کاری باز کرد"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"نمیتوان این محتوا را با برنامههای شخصی همرسانی کرد"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"نمیتوان این محتوا را با برنامههای شخصی باز کرد"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"نمیتوان این محتوا را با برنامههای خصوصی همرسانی کرد"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"نمیتوان این محتوا را با برنامههای خصوصی باز کرد"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"برنامههای کاری موقتاً متوقف شدهاند"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"لغو مکث"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"برنامه کاریای وجود ندارد"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"برنامه شخصیای وجود ندارد"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"بدون برنامه خصوصی"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> در نمایه شخصی باز شود؟"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> در نمایه کاری باز شود؟"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"استفاده از مرورگر شخصی"</string> diff --git a/java/res/values-fi/strings.xml b/java/res/values-fi/strings.xml index 80f39cfa..1456ddf5 100644 --- a/java/res/values-fi/strings.xml +++ b/java/res/values-fi/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tätä sisältöä ei voi avata työsovelluksilla"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tätä sisältöä ei voi jakaa henkilökohtaisilla sovelluksilla"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tätä sisältöä ei voi avata henkilökohtaisilla sovelluksilla"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tätä sisältöä ei voi jakaa yksityisillä sovelluksilla"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tätä sisältöä ei voi avata yksityisillä sovelluksilla"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Työsovellukset on keskeytetty"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Jatka käyttöä"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ei työsovelluksia"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ei henkilökohtaisia sovelluksia"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ei yksityisiä sovelluksia"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Avataanko <xliff:g id="APP">%s</xliff:g> henkilökohtaisessa profiilissa?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Avataanko <xliff:g id="APP">%s</xliff:g> työprofiilissa?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Käytä henkilökohtaista selainta"</string> diff --git a/java/res/values-fr-rCA/strings.xml b/java/res/values-fr-rCA/strings.xml index d485af1e..a8916bb0 100644 --- a/java/res/values-fr-rCA/strings.xml +++ b/java/res/values-fr-rCA/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Impossible d\'ouvrir ce contenu avec des applications professionnelles"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Impossible de partager ce contenu avec des applications personnelles"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Impossible d\'ouvrir ce contenu avec des applications personnelles"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Impossible de partager ce contenu avec des applis privées"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Impossible d\'ouvrir ce contenu avec des applis privées"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les applications professionnelles sont interrompues"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Réactiver"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Aucune application professionnelle"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Aucune application personnelle"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Aucune appli privée"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil personnel?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil professionnel?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utiliser le navigateur du profil personnel"</string> diff --git a/java/res/values-fr/strings.xml b/java/res/values-fr/strings.xml index 2d119863..8d19248f 100644 --- a/java/res/values-fr/strings.xml +++ b/java/res/values-fr/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Impossible d\'ouvrir ce contenu avec des applis professionnelles"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Impossible de partager ce contenu avec des applis personnelles"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Impossible d\'ouvrir ce contenu avec des applis personnelles"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Impossible de partager ce contenu avec des applications privées"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Impossible d\'ouvrir ce contenu avec des applications privées"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les applis professionnelles sont en pause"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Réactiver"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Aucune appli professionnelle"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Aucune appli personnelle"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Aucune application privée"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil personnel ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil professionnel ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utiliser le navigateur personnel"</string> diff --git a/java/res/values-gl/strings.xml b/java/res/values-gl/strings.xml index f4727877..81ab1d16 100644 --- a/java/res/values-gl/strings.xml +++ b/java/res/values-gl/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Este contido non pode abrirse con aplicacións do traballo"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Este contido non pode compartirse con aplicacións persoais"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Este contido non pode abrirse con aplicacións persoais"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Este contido non se pode compartir con aplicacións privadas"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Este contido non se pode abrir con aplicacións privadas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"As aplicacións do traballo están en pausa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactivar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Non hai ningunha aplicación do traballo compatible"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Non hai ningunha aplicación persoal compatible"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Non hai ningunha aplicación privada"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Queres abrir <xliff:g id="APP">%s</xliff:g> no teu perfil persoal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Queres abrir <xliff:g id="APP">%s</xliff:g> no teu perfil de traballo?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utilizar navegador persoal"</string> diff --git a/java/res/values-gu/strings.xml b/java/res/values-gu/strings.xml index 734611cc..5f74da1c 100644 --- a/java/res/values-gu/strings.xml +++ b/java/res/values-gu/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"આ કન્ટેન્ટ ઑફિસ માટેની ઍપ વડે ખોલી શકાતું નથી"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"આ કન્ટેન્ટ વ્યક્તિગત ઍપ સાથે શેર કરી શકાતું નથી"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"આ કન્ટેન્ટ વ્યક્તિગત ઍપ વડે ખોલી શકાતું નથી"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"આ કન્ટેન્ટ ખાનગી ઍપ સાથે શેર કરી શકાતું નથી"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"આ કન્ટેન્ટ ખાનગી ઍપ વડે ખોલી શકાતું નથી"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ઑફિસ માટેની ઍપ થોભાવવામાં આવી છે"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ફરી ચાલુ કરો"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"કોઈ ઑફિસ માટેની ઍપ સપોર્ટ કરતી નથી"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"કોઈ વ્યક્તિગત ઍપ સપોર્ટ કરતી નથી"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"કોઈ ખાનગી ઍપ નથી"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"તમારી વ્યક્તિગત પ્રોફાઇલમાં <xliff:g id="APP">%s</xliff:g> ખોલીએ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"તમારી ઑફિસની પ્રોફાઇલમાં <xliff:g id="APP">%s</xliff:g> ખોલીએ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"વ્યક્તિગત બ્રાઉઝરનો ઉપયોગ કરો"</string> diff --git a/java/res/values-h480dp/dimens.xml b/java/res/values-h480dp/dimens.xml index b5c86c77..74fab4ea 100644 --- a/java/res/values-h480dp/dimens.xml +++ b/java/res/values-h480dp/dimens.xml @@ -22,7 +22,7 @@ <dimen name="resolver_button_bar_spacing">8dp</dimen> <dimen name="chooser_preview_width">-1px</dimen> - <dimen name="chooser_preview_image_height_tall">192dp</dimen> + <dimen name="chooser_preview_image_height_tall">284dp</dimen> <dimen name="grid_padding">10dp</dimen> <dimen name="width_text_image_preview_size">56dp</dimen> </resources> diff --git a/java/res/values-h480dp/integers.xml b/java/res/values-h480dp/integers.xml index c1693057..1195d6b7 100644 --- a/java/res/values-h480dp/integers.xml +++ b/java/res/values-h480dp/integers.xml @@ -15,5 +15,5 @@ --> <resources xmlns:android="http://schemas.android.com/apk/res/android"> - <integer name="text_preview_lines">3</integer> + <integer name="text_preview_lines">8</integer> </resources> diff --git a/java/res/values-hi/strings.xml b/java/res/values-hi/strings.xml index 4ebecdb0..291594fc 100644 --- a/java/res/values-hi/strings.xml +++ b/java/res/values-hi/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"इस कॉन्टेंट को ऑफ़िस के काम से जुड़े ऐप्लिकेशन पर खोला नहीं जा सकता"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"इस कॉन्टेंट को निजी ऐप्लिकेशन के ज़रिए शेयर नहीं किया जा सकता"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"इस कॉन्टेंट को निजी ऐप्लिकेशन पर खोला नहीं जा सकता"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"इस कॉन्टेंट को निजी ऐप्लिकेशन पर शेयर नहीं किया जा सकता"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"इस कॉन्टेंट को निजी ऐप्लिकेशन की मदद से नहीं खोला जा सकता"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"वर्क ऐप्लिकेशन बंद किए गए हैं"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"चालू करें"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"यह कॉन्टेंट, ऑफ़िस के काम से जुड़े आपके किसी भी ऐप्लिकेशन पर खोला नहीं जा सकता"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"यह कॉन्टेंट आपके किसी भी निजी ऐप्लिकेशन पर खोला नहीं जा सकता"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"कोई निजी ऐप्लिकेशन उपलब्ध नहीं है"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"क्या <xliff:g id="APP">%s</xliff:g> को निजी प्रोफ़ाइल में खोलना है?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"क्या <xliff:g id="APP">%s</xliff:g> को वर्क प्रोफ़ाइल में खोलना है?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"निजी ब्राउज़र का इस्तेमाल करें"</string> diff --git a/java/res/values-hr/strings.xml b/java/res/values-hr/strings.xml index 853deacb..0d969c40 100644 --- a/java/res/values-hr/strings.xml +++ b/java/res/values-hr/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Taj se sadržaj ne može otvoriti pomoću poslovnih aplikacija"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Taj se sadržaj ne može dijeliti pomoću osobnih aplikacija"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Taj se sadržaj ne može otvoriti pomoću osobnih aplikacija"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Taj se sadržaj ne može dijeliti pomoću privatnih aplikacija"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Taj se sadržaj ne može otvoriti pomoću privatnih aplikacija"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Poslovne aplikacije su pauzirane"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Ponovno pokreni"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Poslovne aplikacije nisu dostupne"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Osobne aplikacije nisu dostupne"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nema privatnih aplikacija"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Želite li otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na osobnom profilu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Želite li otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na poslovnom profilu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Koristi osobni preglednik"</string> diff --git a/java/res/values-hu/strings.xml b/java/res/values-hu/strings.xml index fa4f23d2..1ecadb77 100644 --- a/java/res/values-hu/strings.xml +++ b/java/res/values-hu/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ez a tartalom nem nyitható meg munkahelyi alkalmazásokkal"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ez a tartalom nem osztható meg személyes alkalmazásokkal"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ez a tartalom nem nyitható meg személyes alkalmazásokkal"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Ez a tartalom nem osztható meg privát alkalmazásokkal"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Ez a tartalom nem nyitható meg privát alkalmazásokkal"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"A munkahelyi alkalmazások szüneteltetve vannak"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Szüneteltetés feloldása"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nincs munkahelyi alkalmazás"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nincs személyes alkalmazás"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nincsenek privát alkalmazások"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Megnyitja a(z) <xliff:g id="APP">%s</xliff:g> alkalmazást a személyes profil használatával?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Megnyitja a(z) <xliff:g id="APP">%s</xliff:g> alkalmazást a munkaprofil használatával?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Személyes böngésző használata"</string> diff --git a/java/res/values-hy/strings.xml b/java/res/values-hy/strings.xml index a7830d80..e4ba3ed4 100644 --- a/java/res/values-hy/strings.xml +++ b/java/res/values-hy/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Այս բովանդակությունը հնարավոր չէ բացել աշխատանքային հավելվածներով"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Այս բովանդակությունը հնարավոր չէ ուղարկել անձնական հավելվածներով"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Այս բովանդակությունը հնարավոր չէ բացել անձնական հավելվածներով"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Այս բովանդակությամբ հնարավոր չէ կիսվել մասնավոր հավելվածներով"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Այս բովանդակությունը հնարավոր չէ բացել մասնավոր հավելվածներով"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Աշխատանքային հավելվածները դադարեցված են"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Նորից միացնել"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Աշխատանքային հավելվածներ չկան"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Անձնական հավելվածներ չկան"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Անձնական հավելվածները չեն աջակցում որոշակի բովանդակություն"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Բացե՞լ <xliff:g id="APP">%s</xliff:g> հավելվածը ձեր անձնական պրոֆիլում"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Բացե՞լ <xliff:g id="APP">%s</xliff:g> հավելվածը ձեր աշխատանքային պրոֆիլում"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Օգտագործել անձնական դիտարկիչը"</string> diff --git a/java/res/values-in/strings.xml b/java/res/values-in/strings.xml index 4107a82d..13c20f36 100644 --- a/java/res/values-in/strings.xml +++ b/java/res/values-in/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Konten ini tidak dapat dibuka dengan aplikasi kerja"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Konten ini tidak dapat dibagikan ke aplikasi pribadi"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Konten ini tidak dapat dibuka dengan aplikasi pribadi"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Konten ini tidak dapat dibagikan dengan aplikasi yang ada di profil privasi"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Konten ini tidak dapat dibuka dengan aplikasi yang ada di profil privasi"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplikasi kerja dijeda"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Batalkan jeda"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Tidak ada aplikasi kerja"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Tidak ada aplikasi pribadi"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Tidak ada aplikasi pribadi"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Buka <xliff:g id="APP">%s</xliff:g> di profil pribadi?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Buka <xliff:g id="APP">%s</xliff:g> di profil kerja?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gunakan browser pribadi"</string> diff --git a/java/res/values-is/strings.xml b/java/res/values-is/strings.xml index a07fc1c8..1fc0f98c 100644 --- a/java/res/values-is/strings.xml +++ b/java/res/values-is/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ekki er hægt að opna þetta efni með vinnuforritum"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ekki er hægt að deila þessu efni með forritum til einkanota"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ekki er hægt að opna þetta efni með forritum til einkanota"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Ekki er hægt að deila þessu efni með einkaforritum"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Ekki er hægt að opna þetta efni með einkaforritum"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Hlé gert á vinnuforritum"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Ljúka hléi"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Engin vinnuforrit"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Engin forrit til einkanota"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Engin einkaforrit"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Opna <xliff:g id="APP">%s</xliff:g> í þínu eigin sniði?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Opna <xliff:g id="APP">%s</xliff:g> í vinnusniðinu þínu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Nota einkavafra"</string> diff --git a/java/res/values-it/strings.xml b/java/res/values-it/strings.xml index 017b9e23..96baefae 100644 --- a/java/res/values-it/strings.xml +++ b/java/res/values-it/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Questi contenuti non possono essere aperti con app di lavoro"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Questi contenuti non possono essere condivisi con app personali"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Questi contenuti non possono essere aperti con app personali"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Questi contenuti non possono essere condivisi con app private"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Questi contenuti non possono essere aperti con app private"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Le app di lavoro sono in pausa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Riattiva"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nessuna app di lavoro"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nessuna app personale"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nessuna app privata"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Aprire <xliff:g id="APP">%s</xliff:g> nel tuo profilo personale?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Aprire <xliff:g id="APP">%s</xliff:g> nel tuo profilo di lavoro?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usa il browser personale"</string> diff --git a/java/res/values-iw/strings.xml b/java/res/values-iw/strings.xml index d1660453..8cfa7bd6 100644 --- a/java/res/values-iw/strings.xml +++ b/java/res/values-iw/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"אי אפשר לפתוח את התוכן הזה באמצעות אפליקציות לעבודה"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"אי אפשר לשתף את התוכן הזה עם אפליקציות לשימוש אישי"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"אי אפשר לפתוח את התוכן הזה באמצעות אפליקציות לשימוש אישי"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"אי אפשר לשתף את התוכן הזה עם אפליקציות פרטיות"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"אי אפשר לפתוח את התוכן הזה באפליקציות פרטיות"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"האפליקציות לעבודה מושהות"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ביטול ההשהיה"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"אין אפליקציות לעבודה"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"אין אפליקציות לשימוש אישי"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"התוכן הזה לא זמין לאפליקציות פרטיות"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"לפתוח את <xliff:g id="APP">%s</xliff:g> בפרופיל האישי?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"לפתוח את <xliff:g id="APP">%s</xliff:g> בפרופיל העבודה?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"בדפדפן האישי"</string> diff --git a/java/res/values-ja/strings.xml b/java/res/values-ja/strings.xml index 6c554f96..d85b6864 100644 --- a/java/res/values-ja/strings.xml +++ b/java/res/values-ja/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{その他 # ファイル}other{その他 # ファイル}}"</string> <string name="sharing_text" msgid="8137537443603304062">"テキストの共有"</string> <string name="sharing_link" msgid="2307694372813942916">"リンクを共有中"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{1 枚の画像を共有します}other{# 枚の画像を共有します}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{画像を共有しています}other{# 枚の画像を共有しています}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{動画を共有中}other{# 個の動画を共有中}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# 個のファイルを共有中}other{# 個のファイルを共有中}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{テキスト付き画像を共有しています}other{テキスト付き画像を # 件共有しています}}"</string> @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"このコンテンツを仕事用アプリで開くことはできません"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"このコンテンツを個人用アプリと共有することはできません"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"このコンテンツを個人用アプリで開くことはできません"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"このコンテンツを限定公開アプリと共有することはできません"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"このコンテンツを限定公開アプリで開くことはできません"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"仕事用アプリ一時停止中"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"一時停止を解除"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"仕事用アプリはありません"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"個人用アプリはありません"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"限定公開アプリは対応していません"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"個人用プロファイルで <xliff:g id="APP">%s</xliff:g> を開きますか?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"仕事用プロファイルで <xliff:g id="APP">%s</xliff:g> を開きますか?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"個人用ブラウザを使用"</string> diff --git a/java/res/values-ka/strings.xml b/java/res/values-ka/strings.xml index 86991fab..91f3b2ac 100644 --- a/java/res/values-ka/strings.xml +++ b/java/res/values-ka/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ამ კონტენტის სამსახურის აპებით გახსნა შეუძლებელია"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ამ კონტენტის პირადი აპებისთვის გაზიარება შეუძლებელია"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ამ კონტენტის პირადი აპებით გახსნა შეუძლებელია"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ამ კონტენტის პირადი აპებისთვის გაზიარება შეუძლებელია"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ამ კონტენტის პირადი აპებით გახსნა შეუძლებელია"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"სამსახურის აპები დაპაუზებულია"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"პაუზის გაუქმება"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"სამსახურის აპები არ არის"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"პირადი აპები არ არის"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"არსებული პირადი აპებით მხარდაჭერილი არ არის"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"გსურთ <xliff:g id="APP">%s</xliff:g>-ის გახსნა თქვენს პირად პროფილში?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"გსურთ <xliff:g id="APP">%s</xliff:g>-ის გახსნა თქვენს სამსახურის პროფილში?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"პირადი ბრაუზერის გამოყენება"</string> diff --git a/java/res/values-kk/strings.xml b/java/res/values-kk/strings.xml index 2ac5fa0e..ce4055f6 100644 --- a/java/res/values-kk/strings.xml +++ b/java/res/values-kk/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Бұл контентті жұмыс қолданбаларымен ашу мүмкін емес."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Бұл контентті жеке қолданбалармен бөлісу мүмкін емес."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Бұл контентті жеке қолданбалармен ашу мүмкін емес."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Бұл контентті жеке қолданбалармен бөлісу мүмкін емес."</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Бұл контентті жеке қолданбалармен ашу мүмкін емес."</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Жұмыс қолданбалары кідіртілген."</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Қайта қосу"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Жұмыс қолданбалары жоқ."</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Жеке қолданбалар жоқ."</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Жеке қолданбалар жоқ."</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> қолданбасын жеке профиліңізде ашу керек пе?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> қолданбасын жұмыс профиліңізде ашу керек пе?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Жеке браузерді пайдалану"</string> diff --git a/java/res/values-km/strings.xml b/java/res/values-km/strings.xml index f0f25e41..b6ff7b70 100644 --- a/java/res/values-km/strings.xml +++ b/java/res/values-km/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ខ្លឹមសារនេះមិនអាចបើកតាមរយៈកម្មវិធីការងារបានទេ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"មិនអាចចែករំលែកខ្លឹមសារនេះជាមួយកម្មវិធីផ្ទាល់ខ្លួនបានទេ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ខ្លឹមសារនេះមិនអាចបើកតាមរយៈកម្មវិធីផ្ទាល់ខ្លួនបានទេ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"មិនអាចចែករំលែកខ្លឹមសារនេះជាមួយកម្មវិធីឯកជនបានទេ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"មិនអាចបើកខ្លឹមសារនេះដោយប្រើកម្មវិធីឯកជនបានទេ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"កម្មវិធីការងារត្រូវបានផ្អាក"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ឈប់ផ្អាក"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"គ្មានកម្មវិធីការងារទេ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"គ្មានកម្មវិធីផ្ទាល់ខ្លួនទេ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"គ្មានកម្មវិធីឯកជនទេ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"បើក <xliff:g id="APP">%s</xliff:g> នៅក្នុងកម្រងព័ត៌មានផ្ទាល់ខ្លួនរបស់អ្នកឬ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"បើក <xliff:g id="APP">%s</xliff:g> នៅក្នុងកម្រងព័ត៌មានការងាររបស់អ្នកឬ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ប្រើកម្មវិធីរុករកតាមអ៊ីនធឺណិតផ្ទាល់ខ្លួន"</string> diff --git a/java/res/values-kn/strings.xml b/java/res/values-kn/strings.xml index 101a1bc0..33abd7f4 100644 --- a/java/res/values-kn/strings.xml +++ b/java/res/values-kn/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳ ಈ ವಿಷಯವನ್ನು ತೆರೆಯಲಾಗುವುದಿಲ್ಲ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ವೈಯಕ್ತಿಕ ಆ್ಯಪ್ಗಳ ಮೂಲಕ ಈ ವಿಷಯವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುವುದಿಲ್ಲ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ವೈಯಕ್ತಿಕ ಆ್ಯಪ್ಗಳ ಮೂಲಕ ಈ ವಿಷಯವನ್ನು ತೆರೆಯಲಾಗುವುದಿಲ್ಲ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ಈ ಕಂಟೆಂಟ್ ಅನ್ನು ಖಾಸಗಿ ಆ್ಯಪ್ಗಳ ಜೊತೆಗೆ ಹಂಚಿಕೊಳ್ಳಲು ಸಾಧ್ಯವಿಲ್ಲ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ಈ ಕಂಟೆಂಟ್ ಅನ್ನು ಖಾಸಗಿ ಆ್ಯಪ್ಗಳ ಮೂಲಕ ತೆರೆಯಲು ಸಾಧ್ಯವಿಲ್ಲ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳನ್ನು ವಿರಾಮಗೊಳಿಸಲಾಗಿದೆ"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ವಿರಾಮವನ್ನು ರದ್ದುಗೊಳಿಸಿ"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ಯಾವುದೇ ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳಿಲ್ಲ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ಯಾವುದೇ ವೈಯಕ್ತಿಕ ಆ್ಯಪ್ಗಳಿಲ್ಲ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ಯಾವುದೇ ಖಾಸಗಿ ಆ್ಯಪ್ಗಳಿಲ್ಲ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"ನಿಮ್ಮ ವೈಯಕ್ತಿಕ ಪ್ರೊಫೈಲ್ನಲ್ಲಿ <xliff:g id="APP">%s</xliff:g> ಅನ್ನು ತೆರೆಯಬೇಕೆ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"ನಿಮ್ಮ ಉದ್ಯೋಗದ ಪ್ರೊಫೈಲ್ನಲ್ಲಿ <xliff:g id="APP">%s</xliff:g> ಅನ್ನು ತೆರೆಯಬೇಕೆ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ವೈಯಕ್ತಿಕ ಬ್ರೌಸರ್ ಬಳಸಿ"</string> diff --git a/java/res/values-ko/strings.xml b/java/res/values-ko/strings.xml index 1b4f2264..9110dc9c 100644 --- a/java/res/values-ko/strings.xml +++ b/java/res/values-ko/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"이 콘텐츠는 직장 앱으로 열 수 없습니다."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"이 콘텐츠는 개인 앱을 통해 공유할 수 없습니다."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"이 콘텐츠는 개인 앱으로 열 수 없습니다."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"이 콘텐츠는 비공개 앱에 공유할 수 없습니다."</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"이 콘텐츠는 비공개 앱으로 열 수 없습니다."</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"직장 앱이 일시중지됨"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"일시중지 해제"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"직장 앱 없음"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"개인 앱 없음"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"비공개 앱을 사용할 수 없습니다."</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"개인 프로필에서 <xliff:g id="APP">%s</xliff:g> 앱을 여시겠습니까?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"직장 프로필에서 <xliff:g id="APP">%s</xliff:g> 앱을 여시겠습니까?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"개인 브라우저 사용"</string> diff --git a/java/res/values-ky/strings.xml b/java/res/values-ky/strings.xml index 33c58be4..b6ac7bb1 100644 --- a/java/res/values-ky/strings.xml +++ b/java/res/values-ky/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Бул нерсени жумуш колдонмолору менен ача албайсыз"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Бул нерсени жеке колдонмолор менен бөлүшө албайсыз"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Бул нерсени жеке колдонмолор менен ача албайсыз"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Бул мазмун жеке колдонмолор менен бөлүшүлбөйт"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Бул мазмун жеке колдонмолор менен ачылбайт"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Жумуш колдонмолору тындырылды"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Иштетүү"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Жумуш колдонмолору жок"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Жеке колдонмолор жок"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Жеке колдонмолор жок"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> колдонмосу жеке профилде ачылсынбы?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> колдонмосу жумуш профилинде ачылсынбы?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Жеке серепчини колдонуу"</string> diff --git a/java/res/values-lo/strings.xml b/java/res/values-lo/strings.xml index 6cb11f82..1cc677ed 100644 --- a/java/res/values-lo/strings.xml +++ b/java/res/values-lo/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກເປີດໄດ້ດ້ວຍແອັບບ່ອນເຮັດວຽກ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກແບ່ງປັນກັບແອັບສ່ວນຕົວໄດ້"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກເປີດໄດ້ດ້ວຍແອັບສ່ວນຕົວ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ບໍ່ສາມາດແບ່ງປັນເນື້ອຫານີ້ໂດຍໃຊ້ແອັບສ່ວນຕົວໄດ້"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ບໍ່ສາມາດເປີດເນື້ອຫານີ້ໂດຍໃຊ້ແອັບສ່ວນຕົວໄດ້"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ຢຸດແອັບບ່ອນເຮັດວຽກໄວ້ຊົ່ວຄາວແລ້ວ"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ຍົກເລີກການຢຸດຊົ່ວຄາວ"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ບໍ່ມີແອັບບ່ອນເຮັດວຽກ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ບໍ່ມີແອັບສ່ວນຕົວ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ບໍ່ມີແອັບສ່ວນຕົວ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"ເປີດ <xliff:g id="APP">%s</xliff:g> ໃນໂປຣໄຟລ໌ສ່ວນຕົວຂອງທ່ານບໍ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"ເປີດ <xliff:g id="APP">%s</xliff:g> ໃນໂປຣໄຟລ໌ບ່ອນເຮັດວຽກຂອງທ່ານບໍ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ໃຊ້ໂປຣແກຣມທ່ອງເວັບສ່ວນຕົວ"</string> diff --git a/java/res/values-lt/strings.xml b/java/res/values-lt/strings.xml index a031b1ae..17971c47 100644 --- a/java/res/values-lt/strings.xml +++ b/java/res/values-lt/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Šio turinio negalima atidaryti naudojant darbo programas"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Šio turinio negalima bendrinti su asmeninėmis programomis"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Šio turinio negalima atidaryti naudojant asmenines programas"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Šio turinio negalima bendrinti su privačiomis programomis"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Šio turinio negalima atidaryti naudojant privačias programas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Darbo programos pristabdytos"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Atšaukti pristabdymą"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nėra darbo programų"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nėra asmeninių programų"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nėra privačių programų"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Atidaryti „<xliff:g id="APP">%s</xliff:g>“ asmeniniame profilyje?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Atidaryti „<xliff:g id="APP">%s</xliff:g>“ darbo profilyje?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Naudoti asmeninę naršyklę"</string> diff --git a/java/res/values-lv/strings.xml b/java/res/values-lv/strings.xml index ead503a4..76070171 100644 --- a/java/res/values-lv/strings.xml +++ b/java/res/values-lv/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Šo saturu nevar atvērt darba lietotnēs"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Šo saturu nevar kopīgot ar personīgajām lietotnēm"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Šo saturu nevar atvērt personīgajās lietotnēs"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Šo saturu nevar kopīgot ar privātajām lietotnēm"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Šo saturu nevar atvērt privātajās lietotnēs"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Darba lietotnes ir apturētas."</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Aktivizēt"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nav darba lietotņu"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nav personīgu lietotņu"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nav privātu lietotņu"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vai atvērt lietotni <xliff:g id="APP">%s</xliff:g> jūsu personīgajā profilā?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vai atvērt lietotni <xliff:g id="APP">%s</xliff:g> jūsu darba profilā?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Izmantot personīgo pārlūku"</string> diff --git a/java/res/values-mk/strings.xml b/java/res/values-mk/strings.xml index 266e7cdb..ceca73ce 100644 --- a/java/res/values-mk/strings.xml +++ b/java/res/values-mk/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Овие содржини не може да се отвораат со работни апликации"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Овие содржини не може да се споделуваат со лични апликации"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Овие содржини не може да се отвораат со лични апликации"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Овие содржини не може да се споделуваат со приватни апликации"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Овие содржини не може да се отвораат со лични апликации"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Работните апликации се паузирани"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Прекини ја паузата"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Нема работни апликации"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Нема лични апликации"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Нема приватни апликации"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Да се отвори <xliff:g id="APP">%s</xliff:g> во личниот профил?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Да се отвори <xliff:g id="APP">%s</xliff:g> во работниот профил?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Користи личен прелистувач"</string> diff --git a/java/res/values-ml/strings.xml b/java/res/values-ml/strings.xml index dece861c..5697294b 100644 --- a/java/res/values-ml/strings.xml +++ b/java/res/values-ml/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ഔദ്യോഗിക ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"വ്യക്തിപര ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം പങ്കിടാനാകില്ല"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"വ്യക്തിപര ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"സ്വകാര്യ ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം പങ്കിടാനാകില്ല"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"സ്വകാര്യ ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ഔദ്യോഗിക ആപ്പുകൾ തൽക്കാലം നിർത്തിയിരിക്കുന്നു"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"താൽക്കാലികമായി നിർത്തിയത് മാറ്റുക"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ഔദ്യോഗിക ആപ്പുകൾ ഇല്ല"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"വ്യക്തിപര ആപ്പുകൾ ഇല്ല"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"സ്വകാര്യ ആപ്പുകൾ ഇല്ല"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g>, നിങ്ങളുടെ വ്യക്തിപരമായ പ്രൊഫൈലിൽ തുറക്കണോ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g>, നിങ്ങളുടെ ഔദ്യോഗിക പ്രൊഫൈലിൽ തുറക്കണോ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"വ്യക്തിപരമായ ബ്രൗസർ ഉപയോഗിക്കുക"</string> diff --git a/java/res/values-mn/strings.xml b/java/res/values-mn/strings.xml index 77ef0edc..4ef02012 100644 --- a/java/res/values-mn/strings.xml +++ b/java/res/values-mn/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Энэ контентыг ажлын аппуудаар нээх боломжгүй"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Энэ контентыг хувийн аппуудаар хуваалцах боломжгүй"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Энэ контентыг хувийн аппуудаар нээх боломжгүй"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Энэ контентыг хувийн аппуудаар хуваалцах боломжгүй"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Энэ контентыг хувийн аппуудаар нээх боломжгүй"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Ажлын аппуудыг түр зогсоосон"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Үргэлжлүүлэх"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ямар ч ажлын апп байхгүй байна"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ямар ч хувийн апп байхгүй байна"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ямар ч хувийн апп байхгүй"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Хувийн профайл дээрээ <xliff:g id="APP">%s</xliff:g>-г нээх үү?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Ажлын профайл дээрээ <xliff:g id="APP">%s</xliff:g>-г нээх үү?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Хувийн хөтөч ашиглах"</string> diff --git a/java/res/values-mr/strings.xml b/java/res/values-mr/strings.xml index b2c24ee7..9eaec274 100644 --- a/java/res/values-mr/strings.xml +++ b/java/res/values-mr/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"हा आशय कार्य ॲप्स वापरून उघडला जाऊ शकत नाही"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"हा आशय वैयक्तिक ॲप्ससह शेअर केला जाऊ शकत नाही"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"हा आशय वैयक्तिक ॲप्स वापरून उघडला जाऊ शकत नाही"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"हा आशय खाजगी ॲप्ससोबत शेअर केला जाऊ शकत नाही"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"हा आशय खाजगी ॲप्स वापरून उघडला जाऊ शकत नाही"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"कामाशी संबंधित अॅप्स थांबवली आहेत"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"पुन्हा सुरू करा"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"कोणतीही कार्य ॲप्स सपोर्ट करत नाहीत"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"कोणतीही वैयक्तिक ॲप्स सपोर्ट करत नाहीत"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"कोणतीही खाजगी अॅप्स नाहीत"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"तुमच्या वैयक्तिक प्रोफाइलमध्ये <xliff:g id="APP">%s</xliff:g> उघडायचे आहे का?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"तुमच्या कार्य प्रोफाइलमध्ये <xliff:g id="APP">%s</xliff:g> उघडायचे आहे का?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"वैयक्तिक ब्राउझर वापरा"</string> diff --git a/java/res/values-ms/strings.xml b/java/res/values-ms/strings.xml index fb1d6422..de0f20d2 100644 --- a/java/res/values-ms/strings.xml +++ b/java/res/values-ms/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Kandungan ini tidak boleh dibuka dengan apl kerja"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Kandungan ini tidak boleh dikongsi dengan apl peribadi"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Kandungan ini tidak boleh dibuka dengan apl peribadi"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Kandungan ini tidak boleh dikongsi dengan apl peribadi"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Kandungan ini tidak boleh dibuka dengan apl peribadi"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Apl kerja dijeda"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Nyahjeda"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Tiada apl kerja"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Tiada apl peribadi"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Tiada apl peribadi"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Buka <xliff:g id="APP">%s</xliff:g> dalam profil peribadi anda?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Buka <xliff:g id="APP">%s</xliff:g> dalam profil kerja anda?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gunakan penyemak imbas peribadi"</string> diff --git a/java/res/values-my/strings.xml b/java/res/values-my/strings.xml index d31d8a48..10fb4b9b 100644 --- a/java/res/values-my/strings.xml +++ b/java/res/values-my/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ဤအကြောင်းအရာကို အလုပ်သုံးအက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ဤအကြောင်းအရာကို ကိုယ်ပိုင်အက်ပ်များဖြင့် မမျှဝေနိုင်ပါ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ဤအကြောင်းအရာကို ကိုယ်ပိုင်အက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ဤအကြောင်းအရာကို သီးသန့်အက်ပ်များဖြင့် မမျှဝေနိုင်ပါ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ဤအကြောင်းအရာကို သီးသန့်အက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"အလုပ်သုံးအက်ပ်များကို ခေတ္တရပ်ထားသည်"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ပြန်ဖွင့်ရန်"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"အလုပ်သုံးအက်ပ်များ မရှိပါ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ကိုယ်ပိုင်အက်ပ်များ မရှိပါ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"သီးသန့်အက်ပ် မရှိပါ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> ကို သင့်ကိုယ်ပိုင်ပရိုဖိုင်တွင် ဖွင့်မလား။"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> ကို သင့်အလုပ်ပရိုဖိုင်တွင် ဖွင့်မလား။"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ကိုယ်ပိုင်ဘရောင်ဇာ သုံးရန်"</string> diff --git a/java/res/values-nb/strings.xml b/java/res/values-nb/strings.xml index ef522229..3aeaf799 100644 --- a/java/res/values-nb/strings.xml +++ b/java/res/values-nb/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Dette innholdet kan ikke åpnes med jobbapper"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Dette innholdet kan ikke deles med personlige apper"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Dette innholdet kan ikke åpnes med personlige apper"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Dette innholdet kan ikke deles med private apper"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Dette innholdet kan ikke åpnes med private apper"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Jobbapper er satt på pause"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Slå av pausen"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ingen jobbapper"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ingen personlige apper"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ingen private apper"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vil du åpne <xliff:g id="APP">%s</xliff:g> i den personlige profilen din?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vil du åpne <xliff:g id="APP">%s</xliff:g> i jobbprofilen din?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Bruk den personlige nettleseren"</string> diff --git a/java/res/values-ne/strings.xml b/java/res/values-ne/strings.xml index f20061bd..1c5ee2f7 100644 --- a/java/res/values-ne/strings.xml +++ b/java/res/values-ne/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"यो सामग्री कामसम्बन्धी एपहरूमार्फत खोल्न मिल्दैन"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"यो सामग्री व्यक्तिगत एपहरूमार्फत सेयर गर्न मिल्दैन"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"यो सामग्री व्यक्तिगत एपहरूमार्फत खोल्न मिल्दैन"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"यो सामग्री निजी एपहरूमार्फत सेयर गर्न मिल्दैन"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"यो सामग्री निजी एपहरूमार्फत खोल्न मिल्दैन"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"कामसम्बन्धी एपहरू पज गरिएका छन्"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"अनपज गर्नुहोस्"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"यो सामग्री खोल्न मिल्ने कुनै पनि कामसम्बन्धी एप छैन"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"यो सामग्री खोल्न मिल्ने कुनै पनि व्यक्तिगत एप छैन"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"कुनै पनि निजी एप छैन"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> तपाईंको व्यक्तिगत प्रोफाइलमा खोल्ने हो?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> तपाईंको कार्य प्रोफाइलमा खोल्ने हो?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"व्यक्तिगत ब्राउजर प्रयोग गर्नुहोस्"</string> diff --git a/java/res/values-nl/strings.xml b/java/res/values-nl/strings.xml index 4f8c48b2..c77ee336 100644 --- a/java/res/values-nl/strings.xml +++ b/java/res/values-nl/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Deze content kan niet worden geopend met werk-apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Deze content kan niet worden gedeeld met persoonlijke apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Deze content kan niet worden geopend met persoonlijke apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Deze content kan niet worden gedeeld met privé-apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Deze content kan niet worden geopend met privé-apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Werk-apps zijn gepauzeerd"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Hervatten"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Geen werk-apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Geen persoonlijke apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Geen privé-apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> openen in je persoonlijke profiel?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> openen in je werkprofiel?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Persoonlijke browser gebruiken"</string> diff --git a/java/res/values-or/strings.xml b/java/res/values-or/strings.xml index b41e4cd2..228af287 100644 --- a/java/res/values-or/strings.xml +++ b/java/res/values-or/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ଏହି ବିଷୟବସ୍ତୁ ୱାର୍କ ଆପଗୁଡ଼ିକରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ଏହି ବିଷୟବସ୍ତୁ ବ୍ୟକ୍ତିଗତ ଆପଗୁଡ଼ିକରେ ସେୟାର୍ କରାଯାଇପାରିବ ନାହିଁ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ଏହି ବିଷୟବସ୍ତୁ ବ୍ୟକ୍ତିଗତ ଆପଗୁଡ଼ିକରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ଏହି ବିଷୟବସ୍ତୁକୁ ପ୍ରାଇଭେଟ ଆପ୍ସରେ ସେୟାର କରାଯାଇପାରିବ ନାହିଁ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ଏହି ବିଷୟବସ୍ତୁକୁ ପ୍ରାଇଭେଟ ଆପ୍ସରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ୱାର୍କ ଆପ୍ସକୁ ବିରତ କରାଯାଇଛି"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ପୁଣି ଚାଲୁ କରନ୍ତୁ"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"କୌଣସି ୱାର୍କ ଆପ୍ ନାହିଁ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"କୌଣସି ବ୍ୟକ୍ତିଗତ ଆପ୍ ନାହିଁ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"କୌଣସି ପ୍ରାଇଭେଟ ଆପ୍ସ ନାହିଁ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g>କୁ ଆପଣଙ୍କ ବ୍ୟକ୍ତିଗତ ପ୍ରୋଫାଇଲରେ ଖୋଲିବେ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g>କୁ ଆପଣଙ୍କ ୱାର୍କ ପ୍ରୋଫାଇଲରେ ଖୋଲିବେ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ବ୍ୟକ୍ତିଗତ ବ୍ରାଉଜର୍ ବ୍ୟବହାର କରନ୍ତୁ"</string> diff --git a/java/res/values-pa/strings.xml b/java/res/values-pa/strings.xml index c098e977..48b92189 100644 --- a/java/res/values-pa/strings.xml +++ b/java/res/values-pa/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨੂੰ ਰੋਕਿਆ ਗਿਆ ਹੈ"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ਰੋਕ ਹਟਾਓ"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ਕੋਈ ਕੰਮ ਸੰਬੰਧੀ ਐਪ ਨਹੀਂ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ਕੋਈ ਨਿੱਜੀ ਐਪ ਨਹੀਂ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ਕੋਈ ਨਿੱਜੀ ਐਪ ਨਹੀਂ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"ਕੀ ਆਪਣੇ ਨਿੱਜੀ ਪ੍ਰੋਫਾਈਲ ਵਿੱਚ <xliff:g id="APP">%s</xliff:g> ਨੂੰ ਖੋਲ੍ਹਣਾ ਹੈ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"ਕੀ ਆਪਣੇ ਕਾਰਜ ਪ੍ਰੋਫਾਈਲ ਵਿੱਚ <xliff:g id="APP">%s</xliff:g> ਨੂੰ ਖੋਲ੍ਹਣਾ ਹੈ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ਨਿੱਜੀ ਬ੍ਰਾਊਜ਼ਰ ਵਰਤੋ"</string> diff --git a/java/res/values-pl/strings.xml b/java/res/values-pl/strings.xml index 9aacdd89..92877b42 100644 --- a/java/res/values-pl/strings.xml +++ b/java/res/values-pl/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tych treści nie można otworzyć w aplikacjach służbowych"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tych treści nie można udostępniać w aplikacjach osobistych"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tych treści nie można otworzyć w aplikacjach osobistych"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tych treści nie można udostępniać w aplikacjach prywatnych"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tych treści nie można otworzyć w aplikacjach prywatnych"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplikacje służbowe są wstrzymane"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Cofnij wstrzymanie"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Brak aplikacji służbowych"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Brak aplikacji osobistych"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Brak prywatnych aplikacji"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Otworzyć aplikację <xliff:g id="APP">%s</xliff:g> w profilu osobistym?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Otworzyć aplikację <xliff:g id="APP">%s</xliff:g> w profilu służbowym?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Użyj przeglądarki osobistej"</string> diff --git a/java/res/values-pt-rBR/strings.xml b/java/res/values-pt-rBR/strings.xml index 575bc5da..b70ffae6 100644 --- a/java/res/values-pt-rBR/strings.xml +++ b/java/res/values-pt-rBR/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir esse conteúdo com apps de trabalho"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Não é possível compartilhar esse conteúdo com apps pessoais"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Não é possível abrir esse conteúdo com apps pessoais"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Não é possível compartilhar esse conteúdo com apps particulares"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Não é possível abrir esse conteúdo com apps particulares"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Os apps de trabalho foram pausados"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reativar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nenhum app de trabalho"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nenhum app pessoal"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Sem apps particulares"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil pessoal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil de trabalho?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar o navegador pessoal"</string> diff --git a/java/res/values-pt-rPT/strings.xml b/java/res/values-pt-rPT/strings.xml index 4b44cb11..46ad2a78 100644 --- a/java/res/values-pt-rPT/strings.xml +++ b/java/res/values-pt-rPT/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir este conteúdo com apps de trabalho"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Não é possível partilhar este conteúdo com apps pessoais"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Não é possível abrir este conteúdo com apps pessoais"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Não é possível partilhar este conteúdo com apps privadas"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Não é possível abrir este conteúdo com apps privadas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"As apps de trabalho estão pausadas"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Retomar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Sem apps de trabalho"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Sem apps pessoais"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nenhuma app privada"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Abrir a app <xliff:g id="APP">%s</xliff:g> no seu perfil pessoal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Abrir a app <xliff:g id="APP">%s</xliff:g> no seu perfil de trabalho?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar navegador pessoal"</string> diff --git a/java/res/values-pt/strings.xml b/java/res/values-pt/strings.xml index 575bc5da..b70ffae6 100644 --- a/java/res/values-pt/strings.xml +++ b/java/res/values-pt/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir esse conteúdo com apps de trabalho"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Não é possível compartilhar esse conteúdo com apps pessoais"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Não é possível abrir esse conteúdo com apps pessoais"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Não é possível compartilhar esse conteúdo com apps particulares"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Não é possível abrir esse conteúdo com apps particulares"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Os apps de trabalho foram pausados"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reativar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nenhum app de trabalho"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nenhum app pessoal"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Sem apps particulares"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil pessoal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil de trabalho?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar o navegador pessoal"</string> diff --git a/java/res/values-ro/strings.xml b/java/res/values-ro/strings.xml index 1839e09a..8029c8b2 100644 --- a/java/res/values-ro/strings.xml +++ b/java/res/values-ro/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Acest conținut nu poate fi deschis cu aplicații pentru lucru"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Acest conținut nu poate fi trimis către aplicații personale"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Acest conținut nu poate fi deschis cu aplicații personale"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Acest conținut nu poate fi trimis cu aplicații private"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Acest conținut nu poate fi deschis cu aplicații private"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplicațiile pentru lucru sunt întrerupte"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactivează"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nicio aplicație pentru lucru"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nicio aplicație personală"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nu există aplicații private"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Deschizi <xliff:g id="APP">%s</xliff:g> în profilul personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Deschizi <xliff:g id="APP">%s</xliff:g> în profilul de serviciu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Folosește browserul personal"</string> diff --git a/java/res/values-ru/strings.xml b/java/res/values-ru/strings.xml index 4f8b265a..a868eb9a 100644 --- a/java/res/values-ru/strings.xml +++ b/java/res/values-ru/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Этот контент нельзя открыть в рабочем приложении."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Этим контентом нельзя делиться с личными приложениями."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Этот контент нельзя открыть в личном приложении."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Этим контентом нельзя делиться через частные приложения."</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Этот контент нельзя открыть в частном приложении."</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Рабочие приложения приостановлены"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Включить"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Не поддерживается рабочими приложениями."</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Не поддерживается личными приложениями."</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Частные приложения не поддерживаются."</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Открыть приложение \"<xliff:g id="APP">%s</xliff:g>\" в личном профиле?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Открыть приложение \"<xliff:g id="APP">%s</xliff:g>\" в рабочем профиле?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Использовать личный браузер"</string> diff --git a/java/res/values-si/strings.xml b/java/res/values-si/strings.xml index 09418f55..0ddf7886 100644 --- a/java/res/values-si/strings.xml +++ b/java/res/values-si/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"මෙම අන්තර්ගතය කාර්යාල යෙදුම් සමඟ විවෘත කළ නොහැකිය"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් සමඟ බෙදා ගත නොහැකිය"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් සමඟ විවෘත කළ නොහැකිය"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් මගින් බෙදා ගත නොහැක"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් මගින් විවෘත කළ නොහැක"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"කාර්යාල යෙදුම් විරාම කර ඇත"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"විරාම නොකරන්න"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"කාර්යාල යෙදුම් නැත"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"පුද්ගලික යෙදුම් නැත"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"පුද්ගලික යෙදුම් නැත"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> ඔබගේ පුද්ගලික පැතිකඩ තුළ විවෘත කරන්නද?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> ඔබගේ කාර්යාල පැතිකඩ තුළ විවෘත කරන්නද?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"පුද්ගලික බ්රව්සරය භාවිත කරන්න"</string> diff --git a/java/res/values-sk/strings.xml b/java/res/values-sk/strings.xml index 96c9cea3..b76a5dc2 100644 --- a/java/res/values-sk/strings.xml +++ b/java/res/values-sk/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tento obsah sa nedá otvoriť pomocou pracovných aplikácií"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tento obsah sa nedá zdieľať pomocou osobných aplikácií"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tento obsah sa nedá otvoriť pomocou osobných aplikácií"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tento obsah sa nedá zdieľať pomocou súkromných aplikácií"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tento obsah sa nedá otvoriť pomocou súkromných aplikácií"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Pracovné aplikácie sú pozastavené"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Zrušiť pozastavenie"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Žiadne pracovné aplikácie"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Žiadne osobné aplikácie"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Žiadne súkromné aplikácie"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Chcete otvoriť <xliff:g id="APP">%s</xliff:g> v osobnom profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Chcete otvoriť <xliff:g id="APP">%s</xliff:g> v pracovnom profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Použiť osobný prehliadač"</string> diff --git a/java/res/values-sl/strings.xml b/java/res/values-sl/strings.xml index 559cf3d1..c68eb0a2 100644 --- a/java/res/values-sl/strings.xml +++ b/java/res/values-sl/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Te vsebine ni mogoče odpreti z delovnimi aplikacijami."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Te vsebine ni mogoče deliti z osebnimi aplikacijami."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Te vsebine ni mogoče odpreti z osebnimi aplikacijami."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Te vsebine ni mogoče deliti z zasebnimi aplikacijami"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Te vsebine ni mogoče odpreti z zasebnimi aplikacijami"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Delovne aplikacije so začasno zaustavljene"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Znova aktiviraj"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nobena delovna aplikacija ni na voljo"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nobena osebna aplikacija"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ni zasebnih aplikacij"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Želite aplikacijo <xliff:g id="APP">%s</xliff:g> odpreti v osebnem profilu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Želite aplikacijo <xliff:g id="APP">%s</xliff:g> odpreti v delovnem profilu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Uporabi osebni brskalnik"</string> diff --git a/java/res/values-sq/strings.xml b/java/res/values-sq/strings.xml index cd7fffee..68a28543 100644 --- a/java/res/values-sq/strings.xml +++ b/java/res/values-sq/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Kjo përmbajtje nuk mund të hapet me aplikacione pune"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Kjo përmbajtje nuk mund të ndahet me aplikacione personale"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Kjo përmbajtje nuk mund të hapet me aplikacione personale"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Kjo përmbajtje nuk mund të ndahet me aplikacione private"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Kjo përmbajtje nuk mund të hapet me aplikacione private"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplikacionet e punës janë vendosur në pauzë"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Hiq nga pauza"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nuk ka aplikacione pune"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nuk ka aplikacione personale"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nuk ka aplikacione private"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Të hapet <xliff:g id="APP">%s</xliff:g> në profilin tënd personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Të hapet <xliff:g id="APP">%s</xliff:g> në profilin tënd të punës?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Përdor shfletuesin personal"</string> diff --git a/java/res/values-sr/strings.xml b/java/res/values-sr/strings.xml index 6eb679b8..a74fa350 100644 --- a/java/res/values-sr/strings.xml +++ b/java/res/values-sr/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Овај садржај не може да се отвара помоћу пословних апликација"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Овај садржај не може да се дели помоћу личних апликација"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Овај садржај не може да се отвара помоћу личних апликација"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Овај садржај не може да се дели помоћу приватних апликација"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Овај садржај не може да се отвори помоћу приватних апликација"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Пословне апликације су паузиране"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Поново активирај"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Нема пословних апликација"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Нема личних апликација"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Без приватних апликација"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Желите да на личном профилу отворите: <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Желите да на пословном профилу отворите: <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Користи лични прегледач"</string> diff --git a/java/res/values-sv/strings.xml b/java/res/values-sv/strings.xml index 43492b9f..3f57f368 100644 --- a/java/res/values-sv/strings.xml +++ b/java/res/values-sv/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Det här innehållet kan inte öppnas med jobbappar"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Det här innehållet kan inte delas med privata appar"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Det här innehållet kan inte öppnas med privata appar"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Det här innehållet kan inte delas med privata appar"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Det här innehållet kan inte öppnas med privata appar"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Jobbappar har pausats"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Återuppta"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Inga jobbappar"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Inga privata appar"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Inga privata appar"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vill du öppna <xliff:g id="APP">%s</xliff:g> i din privata profil?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vill du öppna <xliff:g id="APP">%s</xliff:g> i din jobbprofil?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Använd privat webbläsare"</string> diff --git a/java/res/values-sw/strings.xml b/java/res/values-sw/strings.xml index e9cddfb4..b29737c8 100644 --- a/java/res/values-sw/strings.xml +++ b/java/res/values-sw/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Huwezi kufungua maudhui haya ukitumia programu za kazini"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Huwezi kushiriki maudhui haya na programu za binafsi"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Huwezi kufungua maudhui haya ukitumia programu za binafsi"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Programu za faragha haziruhusiwi kufikia maudhui haya"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Huwezi kufungua maudhui haya ukitumia programu za faragha"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Programu za kazini zimesitishwa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Acha kusitisha"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Hakuna programu za kazini"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Hakuna programu za binafsi"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Hakuna programu za faragha"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Je, unataka kufungua <xliff:g id="APP">%s</xliff:g> katika wasifu wako binafsi?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Je, unataka kufungua <xliff:g id="APP">%s</xliff:g> katika wasifu wako wa kazi?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Tumia kivinjari cha binafsi"</string> diff --git a/java/res/values-ta/strings.xml b/java/res/values-ta/strings.xml index bef51c34..3d0333fd 100644 --- a/java/res/values-ta/strings.xml +++ b/java/res/values-ta/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"பணி ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"தனிப்பட்ட ஆப்ஸுடன் இந்த உள்ளடக்கத்தைப் பகிர முடியாது"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"தனிப்பட்ட ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"தனிப்பட்ட ஆப்ஸுடன் இந்த உள்ளடக்கத்தைப் பகிர முடியாது"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"தனிப்பட்ட ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"பணி ஆப்ஸ் இடைநிறுத்தப்பட்டுள்ளன"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"மீண்டும் இயக்கு"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"பணி ஆப்ஸ் எதுவுமில்லை"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"தனிப்பட்ட ஆப்ஸ் எதுவுமில்லை"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"தனிப்பட்ட ஆப்ஸ் எதுவுமில்லை"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"உங்கள் தனிப்பட்ட கணக்கில் <xliff:g id="APP">%s</xliff:g> ஆப்ஸைத் திறக்கவா?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"உங்கள் பணிக் கணக்கில் <xliff:g id="APP">%s</xliff:g> ஆப்ஸைத் திறக்கவா?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"தனிப்பட்ட உலாவியைப் பயன்படுத்து"</string> diff --git a/java/res/values-te/strings.xml b/java/res/values-te/strings.xml index 30f45be5..0ddb058b 100644 --- a/java/res/values-te/strings.xml +++ b/java/res/values-te/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ఈ కంటెంట్ వర్క్ యాప్తో తెరవడం సాధ్యం కాదు"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ఈ కంటెంట్ను వ్యక్తిగత యాప్స్ లోకి షేర్ చేయడం సాధ్యం కాదు"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ఈ కంటెంట్ వ్యక్తిగత యాప్తో తెరవడం సాధ్యం కాదు"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ఈ కంటెంట్ను ప్రైవేట్ యాప్లతో షేర్ చేయడం సాధ్యం కాదు"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ఈ కంటెంట్ను ప్రైవేట్ యాప్లతో తెరవడం సాధ్యం కాదు"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"వర్క్ యాప్లు పాజ్ చేయబడ్డాయి"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"అన్పాజ్ చేయండి"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"వర్క్ యాప్లు లేవు"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"వ్యక్తిగత యాప్లు లేవు"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ప్రైవేట్ యాప్లు ఏవీ లేవు"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g>ను మీ వ్యక్తిగత ప్రొఫైల్లో తెరవాలా?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g>ను మీ వర్క్ ప్రొఫైల్లో తెరవాలా?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"వ్యక్తిగత బ్రౌజర్ను ఉపయోగించండి"</string> diff --git a/java/res/values-th/strings.xml b/java/res/values-th/strings.xml index 8db86fff..1c3e5f86 100644 --- a/java/res/values-th/strings.xml +++ b/java/res/values-th/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"เปิดเนื้อหานี้โดยใช้แอปงานไม่ได้"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"แชร์เนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"เปิดเนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"แชร์เนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"เปิดเนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"แอปงานหยุดชั่วคราว"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ยกเลิกการหยุดชั่วคราว"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ไม่มีแอปงาน"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ไม่มีแอปส่วนตัว"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ไม่มีแอปส่วนตัว"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"เปิด <xliff:g id="APP">%s</xliff:g> ในโปรไฟล์ส่วนตัวไหม"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"เปิด <xliff:g id="APP">%s</xliff:g> ในโปรไฟล์งานไหม"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ใช้เบราว์เซอร์ส่วนตัว"</string> diff --git a/java/res/values-tl/strings.xml b/java/res/values-tl/strings.xml index 59d51005..f845ae41 100644 --- a/java/res/values-tl/strings.xml +++ b/java/res/values-tl/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Hindi puwedeng buksan sa mga app para sa trabaho ang content na ito"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Hindi puwedeng ibahagi sa mga personal na app ang content na ito"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Hindi puwedeng buksan sa mga personal na app ang content na ito"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Hindi maibabahagi ang content na ito sa mga pribadong app"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Hindi mabubuksan ang content na ito gamit ang mga pribadong app"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Naka-pause ang mga app para sa trabaho"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"I-unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Walang app para sa trabaho"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Walang personal na app"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Walang pribadong app"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Buksan ang <xliff:g id="APP">%s</xliff:g> sa iyong personal na profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Buksan ang <xliff:g id="APP">%s</xliff:g> sa iyong profile sa trabaho?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gamitin ang personal na browser"</string> diff --git a/java/res/values-tr/strings.xml b/java/res/values-tr/strings.xml index eadfeeaa..e46c3e43 100644 --- a/java/res/values-tr/strings.xml +++ b/java/res/values-tr/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu içerik, iş uygulamalarıyla açılamaz"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bu içerik, kişisel uygulamalarla paylaşılamaz"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bu içerik, kişisel uygulamalarla açılamaz"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Bu içerik, özel uygulamalarla paylaşılamaz"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Bu içerik, özel uygulamalarla açılamaz"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"İş uygulamaları duraklatıldı"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Devam ettir"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"İş uygulaması yok"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Kişisel uygulama yok"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Özel uygulama yok"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> uygulaması kişisel profilinizde açılsın mı?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> uygulaması iş profilinizde açılsın mı?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Kişisel tarayıcıyı kullan"</string> diff --git a/java/res/values-uk/strings.xml b/java/res/values-uk/strings.xml index a517db45..f9635aea 100644 --- a/java/res/values-uk/strings.xml +++ b/java/res/values-uk/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Цей контент не можна відкривати в робочих додатках"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Цим контентом не можна ділитися в особистих додатках"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Цей контент не можна відкривати в особистих додатках"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Цим контентом не можна ділитися в приватних додатках"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Цей контент не можна відкривати в приватних додатках"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Робочі додатки призупинено"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Увімкнути знову"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Немає робочих додатків"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Немає особистих додатків"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Немає приватних додатків"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Відкрити додаток <xliff:g id="APP">%s</xliff:g> в особистому профілі?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Відкрити додаток <xliff:g id="APP">%s</xliff:g> у робочому профілі?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Використати особистий веб-переглядач"</string> diff --git a/java/res/values-ur/strings.xml b/java/res/values-ur/strings.xml index 716a99af..858ca9c7 100644 --- a/java/res/values-ur/strings.xml +++ b/java/res/values-ur/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"اس مواد کو ورک ایپس کے ساتھ نہیں کھولا جا سکتا"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"اس مواد کا اشتراک ذاتی ایپس کے ساتھ نہیں کیا جا سکتا"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"اس مواد کو ذاتی ایپس کے ساتھ نہیں کھولا جا سکتا"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"اس مواد کا اشتراک نجی ایپس کے ساتھ نہیں کیا جا سکتا"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"اس مواد کو ذاتی ایپس کے ساتھ نہیں کھولا جا سکتا"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ورک ایپس موقوف ہیں"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"غیر موقوف کریں"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"کوئی ورک ایپ نہیں"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"کوئی ذاتی ایپ نہیں"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"کوئی نجی ایپ نہیں ہے"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"اپنی ذاتی پروفائل میں <xliff:g id="APP">%s</xliff:g> کھولیں؟"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"اپنی دفتری پروفائل میں <xliff:g id="APP">%s</xliff:g> کھولیں؟"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ذاتی براؤزر استعمال کریں"</string> diff --git a/java/res/values-uz/strings.xml b/java/res/values-uz/strings.xml index d8e0bab7..047c2ee3 100644 --- a/java/res/values-uz/strings.xml +++ b/java/res/values-uz/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu kontent ishga oid ilovalar bilan ochilmaydi"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bu kontent shaxsiy ilovalar bilan ulashilmaydi"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bu kontent shaxsiy ilovalar bilan ochilmaydi"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Bu kontent shaxsiy ilovalar orqali ulashilmaydi"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Bu kontent shaxsiy ilovalar orqali ochilmaydi"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Ishga oid ilovalar pauza qilingan"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Davom ettirish"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ishga oid ilovalar topilmadi"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Shaxsiy ilovalar topilmadi"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Shaxsiy ilovalar ishlamaydi"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> shaxsiy profilda ochilsinmi?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> shaxsiy profilda ochilsinmi?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Shaxsiy brauzerdan foydalanish"</string> diff --git a/java/res/values-vi/strings.xml b/java/res/values-vi/strings.xml index a8d70cfc..89260efe 100644 --- a/java/res/values-vi/strings.xml +++ b/java/res/values-vi/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bạn không thể mở nội dung này bằng ứng dụng công việc"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bạn không thể chia sẻ nội dung này bằng ứng dụng cá nhân"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bạn không thể mở nội dung này bằng ứng dụng cá nhân"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Không chia sẻ được nội dung này bằng ứng dụng riêng tư"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Không mở được nội dung này bằng ứng dụng riêng tư"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Các ứng dụng công việc đã bị tạm dừng"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Tiếp tục"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Không có ứng dụng công việc"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Không có ứng dụng cá nhân"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Không có ứng dụng riêng tư nào"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Mở <xliff:g id="APP">%s</xliff:g> trong hồ sơ cá nhân của bạn?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Mở <xliff:g id="APP">%s</xliff:g> trong hồ sơ công việc của bạn?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Dùng trình duyệt cá nhân"</string> diff --git a/java/res/values-zh-rCN/strings.xml b/java/res/values-zh-rCN/strings.xml index 504bac6a..9049644a 100644 --- a/java/res/values-zh-rCN/strings.xml +++ b/java/res/values-zh-rCN/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"无法使用工作应用打开该内容"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"无法使用个人应用分享该内容"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"无法使用个人应用打开该内容"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"无法使用私人应用分享该内容"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"无法使用私人应用打开该内容"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"工作应用已暂停"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"解除暂停"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"没有支持该内容的工作应用"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"没有支持该内容的个人应用"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"无可用的专用应用"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"要使用个人资料打开 <xliff:g id="APP">%s</xliff:g> 吗?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"要使用工作资料打开 <xliff:g id="APP">%s</xliff:g> 吗?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"使用个人浏览器"</string> diff --git a/java/res/values-zh-rHK/strings.xml b/java/res/values-zh-rHK/strings.xml index c54fc4b5..bab7dc9a 100644 --- a/java/res/values-zh-rHK/strings.xml +++ b/java/res/values-zh-rHK/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"無法使用工作應用程式開啟此內容"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"無法與個人應用程式分享此內容"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"無法使用個人應用程式開啟此內容"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"無法使用私人應用程式分享此內容"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"無法使用私人應用程式開啟此內容"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"已暫停工作應用程式"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"取消暫停"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"沒有適用的工作應用程式"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"沒有適用的個人應用程式"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"沒有私人應用程式"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"要在個人設定檔中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"要在工作設定檔中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"使用個人瀏覽器"</string> diff --git a/java/res/values-zh-rTW/strings.xml b/java/res/values-zh-rTW/strings.xml index 288602f4..88172c78 100644 --- a/java/res/values-zh-rTW/strings.xml +++ b/java/res/values-zh-rTW/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"無法使用工作應用程式開啟這項內容"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"無法與個人應用程式分享這項內容"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"無法使用個人應用程式開啟這項內容"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"無法透過私人應用程式分享這項內容"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"無法使用私人應用程式開啟這項內容"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"工作應用程式目前為暫停狀態"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"取消暫停"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"沒有適用的工作應用程式"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"沒有適用的個人應用程式"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"私人應用程式不支援這項功能"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"要在個人資料夾中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"要在工作資料夾中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"使用個人瀏覽器"</string> diff --git a/java/res/values-zu/strings.xml b/java/res/values-zu/strings.xml index e30f51fd..c845de43 100644 --- a/java/res/values-zu/strings.xml +++ b/java/res/values-zu/strings.xml @@ -86,10 +86,13 @@ <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Lokhu okuqukethwe akukwazi ukukopishwa ngama-app womsebenzi"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Lokhu okuqukethwe akukwazi ukwabiwa nama-app womuntu siqu"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Lokhu okuqukethwe akukwazi ukukopishwa ngama-app womuntu siqu"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Lokhu okuqukethwe akukwazi ukwabiwa ngama-app agodliwe"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Lokhu okuqukethwe akukwazi ukuvulwa ngama-app agodliwe"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Ama-app omsebenzi amisiwe"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Qhubekisa"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Awekho ama-app womsebenzi"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Awekho ama-app womuntu siqu"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Awekho ama-app ayimfihlo"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vula i-<xliff:g id="APP">%s</xliff:g> kwiphrofayela yakho siqu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vula i-<xliff:g id="APP">%s</xliff:g> kwiphrofayela yakho yomsebenzi?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Sebenzisa isiphequluli somuntu siqu"</string> diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index 9d77d296..bd868c9f 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -58,7 +58,7 @@ <!-- Note that the values in this section are for landscape phones. For screen configs taller than 480dp, the values are set in values-h480dp/dimens.xml --> <dimen name="chooser_preview_width">412dp</dimen> - <dimen name="chooser_preview_image_height_tall">46dp</dimen> + <dimen name="chooser_preview_image_height_tall">124dp</dimen> <dimen name="grid_padding">8dp</dimen> <dimen name="width_text_image_preview_size">46dp</dimen> <!-- END SECTION --> diff --git a/java/res/values/integers.xml b/java/res/values/integers.xml index 6d57e43e..8d203bca 100644 --- a/java/res/values/integers.xml +++ b/java/res/values/integers.xml @@ -17,5 +17,5 @@ <resources> <!-- Note that this is the value for landscape phones, the value for all screens taller than 480dp is set in values-h480dp/integers.xml --> - <integer name="text_preview_lines">1</integer> + <integer name="text_preview_lines">3</integer> </resources> diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 5c1210b7..c026ee59 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -284,6 +284,12 @@ <!-- Error message. This message lets the user know that their IT admin doesn't allow them to open this specific content with an app in their personal profile. [CHAR LIMIT=NONE] --> <string name="resolver_cant_access_personal_apps_explanation">This content can\u2019t be opened with personal apps</string> + <!-- Error message. This text is explaining that the user's IT admin doesn't allow this specific content to be shared with apps in the private profile. [CHAR LIMIT=NONE] --> + <string name="resolver_cant_share_with_private_apps_explanation">This content can\u2019t be shared with private apps</string> + + <!-- Error message. This message lets the user know that their IT admin doesn't allow them to open this specific content with an app in their private profile. [CHAR LIMIT=NONE] --> + <string name="resolver_cant_access_private_apps_explanation">This content can\u2019t be opened with private apps</string> + <!-- Error message. This text lets the user know that they need to turn on work apps in order to share or open content. There's also a button a user can tap to turn on the apps. [CHAR LIMIT=NONE] --> <string name="resolver_turn_on_work_apps">Work apps are paused</string> <!-- Button text. This button unpauses a user's work apps and data. [CHAR LIMIT=NONE] --> @@ -295,6 +301,9 @@ <!-- Error message. This text lets the user know that their current personal apps don't support the specific content. [CHAR LIMIT=NONE] --> <string name="resolver_no_personal_apps_available">No personal apps</string> + <!-- Error message. This text lets the user know that their current private apps don't support the specific content. [CHAR LIMIT=NONE] --> + <string name="resolver_no_private_apps_available">No private apps</string> + <!-- Dialog title. User must choose between opening content in a cross-profile app or same-profile browser. [CHAR LIMIT=NONE] --> <string name="miniresolver_open_in_personal">Open <xliff:g id="app" example="YouTube">%s</xliff:g> in your personal profile?</string> <!-- Dialog title. User must choose between opening content in a cross-profile app or same-profile browser. [CHAR LIMIT=NONE] --> @@ -313,7 +322,17 @@ <!-- Title for a button. Adds back a (previously excluded) web link into the shared content. --> <string name="include_link">Include link</string> - <!-- Accesssibility content description for a sharesheet target that has been pinned to the + <!-- Accessibility content description for a sharesheet target that has been pinned to the front of the list by the user. [CHAR LIMIT=NONE] --> <string name="pinned">Pinned</string> + + <!-- Accessibility content description for an image that the user may select for sharing. + [CHAR LIMIT=NONE] --> + <string name="selectable_image">Selectable image</string> + <!-- Accessibility content description for a video that the user may select for sharing. + [CHAR LIMIT=NONE] --> + <string name="selectable_video">Selectable video</string> + <!-- Accessibility content description for an item that the user may select for sharing. + [CHAR LIMIT=NONE] --> + <string name="selectable_item">Selectable item</string> </resources> diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml index 0ccab4c0..143009d0 100644 --- a/java/res/values/styles.xml +++ b/java/res/values/styles.xml @@ -45,7 +45,7 @@ <style name="Theme.DeviceDefault.Chooser" parent="Theme.DeviceDefault.Resolver"> <item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item> <item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item> - <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> + <item name="android:windowLayoutInDisplayCutoutMode">always</item> </style> <style name="TextAppearance.ChooserDefault" diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java deleted file mode 100644 index 3565e757..00000000 --- a/java/src/com/android/intentresolver/AnnotatedUserHandles.java +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.app.Activity; -import android.app.ActivityManager; -import android.os.UserHandle; -import android.os.UserManager; - -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -/** - * Helper class to precompute the (immutable) designations of various user handles in the system - * that may contribute to the current Sharesheet session. - */ -public final class AnnotatedUserHandles { - /** The user id of the app that started the share activity. */ - public final int userIdOfCallingApp; - - /** - * The {@link UserHandle} that launched Sharesheet. - * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp} - * except possibly if the caller used {@link Activity#startActivityAsUser} to launch - * Sharesheet as a different user than they themselves were running as. Verify and document. - */ - public final UserHandle userHandleSharesheetLaunchedAs; - - /** - * The {@link UserHandle} that owns the "personal tab" in a tabbed share UI (or the *only* 'tab' - * in a non-tabbed UI). - * - * This is never a work or clone user, but may either be the root user (0) or a "secondary" - * multi-user profile (i.e., one that's not root, work, nor clone). This is a "secondary" - * profile only when that user is the active "foreground" user. - * - * In the current implementation, we can assert that this is the root user (0) any time we - * display a tabbed UI (i.e., any time `workProfileUserHandle` is non-null), or any time that we - * have a clone profile. This note is only provided for informational purposes; clients should - * avoid making any reliances on that assumption. - */ - public final UserHandle personalProfileUserHandle; - - /** - * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary) - * one of the "managed" profiles associated with {@link #personalProfileUserHandle}. - */ - @Nullable - public final UserHandle workProfileUserHandle; - - /** - * The {@link UserHandle} of the clone profile belonging to {@link #personalProfileUserHandle}. - */ - @Nullable - public final UserHandle cloneProfileUserHandle; - - /** - * The "tab owner" user handle (i.e., either {@link #personalProfileUserHandle} or - * {@link #workProfileUserHandle}) that either matches or owns the profile of the - * {@link #userHandleSharesheetLaunchedAs}. - * - * In the current implementation, we can assert that this is the same as - * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is - * the "personal" profile owning that clone profile (which we currently know must belong to - * user 0, but clients should avoid making any reliances on that assumption). - */ - public final UserHandle tabOwnerUserHandleForLaunch; - - /** Compute all handle designations for a new Sharesheet session in the specified activity. */ - public static AnnotatedUserHandles forShareActivity(Activity shareActivity) { - // TODO: consider integrating logic for `ResolverActivity.EXTRA_CALLING_USER`? - UserHandle userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId()); - - // ActivityManager.getCurrentUser() refers to the current Foreground user. When clone/work - // profile is active, we always make the personal tab from the foreground user. - // Outside profiles, current foreground user is potentially the same as the sharesheet - // process's user (UserHandle.myUserId()), so we continue to create personal tab with the - // current foreground user. - UserHandle personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser()); - - UserManager userManager = shareActivity.getSystemService(UserManager.class); - - return newBuilder() - .setUserIdOfCallingApp(shareActivity.getLaunchedFromUid()) - .setUserHandleSharesheetLaunchedAs(userHandleSharesheetLaunchedAs) - .setPersonalProfileUserHandle(personalProfileUserHandle) - .setWorkProfileUserHandle( - getWorkProfileForUser(userManager, personalProfileUserHandle)) - .setCloneProfileUserHandle( - getCloneProfileForUser(userManager, personalProfileUserHandle)) - .build(); - } - - @VisibleForTesting public static Builder newBuilder() { - return new Builder(); - } - - /** - * Returns the {@link UserHandle} to use when querying resolutions for intents in a - * {@link ResolverListController} configured for the provided {@code userHandle}. - */ - public UserHandle getQueryIntentsUser(UserHandle userHandle) { - // In case launching app is in clonedProfile, and we are building the personal tab, intent - // resolution will be attempted as clonedUser instead of user 0. This is because intent - // resolution from user 0 and clonedUser is not guaranteed to return same results. - // We do not care about the case when personal adapter is started with non-root user - // (secondary user case), as clone profile is guaranteed to be non-active in that case. - UserHandle queryIntentsUser = userHandle; - if (isLaunchedAsCloneProfile() && userHandle.equals(personalProfileUserHandle)) { - queryIntentsUser = cloneProfileUserHandle; - } - return queryIntentsUser; - } - - private Boolean isLaunchedAsCloneProfile() { - return userHandleSharesheetLaunchedAs.equals(cloneProfileUserHandle); - } - - private AnnotatedUserHandles( - int userIdOfCallingApp, - UserHandle userHandleSharesheetLaunchedAs, - UserHandle personalProfileUserHandle, - @Nullable UserHandle workProfileUserHandle, - @Nullable UserHandle cloneProfileUserHandle) { - if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) { - throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp); - } - - this.userIdOfCallingApp = userIdOfCallingApp; - this.userHandleSharesheetLaunchedAs = userHandleSharesheetLaunchedAs; - this.personalProfileUserHandle = personalProfileUserHandle; - this.workProfileUserHandle = workProfileUserHandle; - this.cloneProfileUserHandle = cloneProfileUserHandle; - this.tabOwnerUserHandleForLaunch = - (userHandleSharesheetLaunchedAs == workProfileUserHandle) - ? workProfileUserHandle : personalProfileUserHandle; - } - - @Nullable - private static UserHandle getWorkProfileForUser( - UserManager userManager, UserHandle profileOwnerUserHandle) { - return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()) - .stream() - .filter(info -> info.isManagedProfile()) - .findFirst() - .map(info -> info.getUserHandle()) - .orElse(null); - } - - @Nullable - private static UserHandle getCloneProfileForUser( - UserManager userManager, UserHandle profileOwnerUserHandle) { - return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()) - .stream() - .filter(info -> info.isCloneProfile()) - .findFirst() - .map(info -> info.getUserHandle()) - .orElse(null); - } - - @VisibleForTesting - public static class Builder { - private int mUserIdOfCallingApp; - private UserHandle mUserHandleSharesheetLaunchedAs; - private UserHandle mPersonalProfileUserHandle; - private UserHandle mWorkProfileUserHandle; - private UserHandle mCloneProfileUserHandle; - - public Builder setUserIdOfCallingApp(int id) { - mUserIdOfCallingApp = id; - return this; - } - - public Builder setUserHandleSharesheetLaunchedAs(UserHandle user) { - mUserHandleSharesheetLaunchedAs = user; - return this; - } - - public Builder setPersonalProfileUserHandle(UserHandle user) { - mPersonalProfileUserHandle = user; - return this; - } - - public Builder setWorkProfileUserHandle(UserHandle user) { - mWorkProfileUserHandle = user; - return this; - } - - public Builder setCloneProfileUserHandle(UserHandle user) { - mCloneProfileUserHandle = user; - return this; - } - - public AnnotatedUserHandles build() { - return new AnnotatedUserHandles( - mUserIdOfCallingApp, - mUserHandleSharesheetLaunchedAs, - mPersonalProfileUserHandle, - mWorkProfileUserHandle, - mCloneProfileUserHandle); - } - } -} diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 310fcc27..79998fbc 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -39,6 +39,8 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.ui.ShareResultSender; +import com.android.intentresolver.ui.model.ShareAction; import com.android.intentresolver.widget.ActionRow; import com.android.internal.annotations.VisibleForTesting; @@ -46,6 +48,7 @@ import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.Callable; import java.util.function.Consumer; @@ -53,8 +56,11 @@ import java.util.function.Consumer; * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application * requirements of Sharesheet / {@link ChooserActivity}. */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { - /** Delegate interface to launch activities when the actions are selected. */ + /** + * Delegate interface to launch activities when the actions are selected. + */ public interface ActionActivityStarter { /** * Request an activity launch for the provided target. Implementations may choose to exit @@ -92,19 +98,17 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Context mContext; - @Nullable - private final Runnable mCopyButtonRunnable; - private final Runnable mEditButtonRunnable; + @Nullable private Runnable mCopyButtonRunnable; + @Nullable private Runnable mEditButtonRunnable; private final ImmutableList<ChooserAction> mCustomActions; - private final @Nullable ChooserAction mModifyShareAction; private final Consumer<Boolean> mExcludeSharedTextAction; + @Nullable private final ShareResultSender mShareResultSender; private final Consumer</* @Nullable */ Integer> mFinishCallback; private final EventLog mLog; /** * @param context - * @param chooserRequest data about the invocation of the current Sharesheet session. - * device to implement the supported action types. + * @param imageEditor an explicit Activity to launch for editing images * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" * setting is updated. The argument is whether the shared text is to be excluded. * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image @@ -115,54 +119,74 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio */ public ChooserActionFactory( Context context, - ChooserRequestParameters chooserRequest, - ChooserIntegratedDeviceComponents integratedDeviceComponents, + Intent targetIntent, + String referrerPackageName, + List<ChooserAction> chooserActions, + Optional<ComponentName> imageEditor, EventLog log, Consumer<Boolean> onUpdateSharedTextIsExcluded, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, - Consumer</* @Nullable */ Integer> finishCallback) { + @Nullable ShareResultSender shareResultSender, + Consumer</* @Nullable */ Integer> finishCallback, + ClipboardManager clipboardManager) { this( context, makeCopyButtonRunnable( - context, - chooserRequest.getTargetIntent(), - chooserRequest.getReferrerPackageName(), + clipboardManager, + targetIntent, + referrerPackageName, finishCallback, log), makeEditButtonRunnable( getEditSharingTarget( context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), + targetIntent, + imageEditor), firstVisibleImageQuery, activityStarter, log), - chooserRequest.getChooserActions(), - chooserRequest.getModifyShareAction(), + chooserActions, onUpdateSharedTextIsExcluded, log, + shareResultSender, finishCallback); + } @VisibleForTesting ChooserActionFactory( Context context, @Nullable Runnable copyButtonRunnable, - Runnable editButtonRunnable, + @Nullable Runnable editButtonRunnable, List<ChooserAction> customActions, - @Nullable ChooserAction modifyShareAction, Consumer<Boolean> onUpdateSharedTextIsExcluded, EventLog log, + @Nullable ShareResultSender shareResultSender, Consumer</* @Nullable */ Integer> finishCallback) { mContext = context; mCopyButtonRunnable = copyButtonRunnable; mEditButtonRunnable = editButtonRunnable; mCustomActions = ImmutableList.copyOf(customActions); - mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; mLog = log; + mShareResultSender = shareResultSender; mFinishCallback = finishCallback; + + if (mShareResultSender != null) { + if (mEditButtonRunnable != null) { + mEditButtonRunnable = () -> { + mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); + editButtonRunnable.run(); + }; + } + if (mCopyButtonRunnable != null) { + mCopyButtonRunnable = () -> { + mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); + copyButtonRunnable.run(); + }; + } + } } @Override @@ -186,11 +210,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ActionRow.Action actionRow = createCustomAction( mContext, mCustomActions.get(i), - mFinishCallback, - () -> { - mLog.logCustomActionSelected(position); - } - ); + () -> logCustomAction(position), + mShareResultSender, + mFinishCallback); if (actionRow != null) { actions.add(actionRow); } @@ -199,21 +221,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } /** - * Provides a share modification action, if any. - */ - @Override - @Nullable - public ActionRow.Action getModifyShareAction() { - return createCustomAction( - mContext, - mModifyShareAction, - mFinishCallback, - () -> { - mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); - }); - } - - /** * <p> * Creates an exclude-text action that can be called when the user changes shared text * status in the Media + Text preview. @@ -229,7 +236,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable private static Runnable makeCopyButtonRunnable( - Context context, + ClipboardManager clipboardManager, Intent targetIntent, String referrerPackageName, Consumer<Integer> finishCallback, @@ -245,8 +252,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return null; } return () -> { - ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( - Context.CLIPBOARD_SERVICE); clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); log.logActionSelected(EventLog.SELECTION_TYPE_COPY); @@ -278,18 +283,18 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return clipData; } + @Nullable private static TargetInfo getEditSharingTarget( Context context, Intent originalIntent, - ChooserIntegratedDeviceComponents integratedComponents) { - final ComponentName editorComponent = integratedComponents.getEditSharingComponent(); + Optional<ComponentName> imageEditor) { final Intent resolveIntent = new Intent(originalIntent); // Retain only URI permission grant flags if present. Other flags may prevent the scene // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); - resolveIntent.setComponent(editorComponent); + imageEditor.ifPresent(resolveIntent::setComponent); resolveIntent.setAction(Intent.ACTION_EDIT); resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); String originalAction = originalIntent.getAction(); @@ -308,7 +313,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio final ResolveInfo ri = context.getPackageManager().resolveActivity( resolveIntent, PackageManager.GET_META_DATA); if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available"); + Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); return null; } @@ -323,11 +328,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return dri; } + @Nullable private static Runnable makeEditButtonRunnable( - TargetInfo editSharingTarget, + @Nullable TargetInfo editSharingTarget, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, EventLog log) { + if (editSharingTarget == null) return null; return () -> { // Log share completion via edit. log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); @@ -347,12 +354,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } @Nullable - private static ActionRow.Action createCustomAction( + static ActionRow.Action createCustomAction( Context context, - ChooserAction action, - Consumer<Integer> finishCallback, - Runnable loggingRunnable) { - if (action == null || action.getAction() == null) { + @Nullable ChooserAction action, + Runnable loggingRunnable, + ShareResultSender shareResultSender, + Consumer</* @Nullable */ Integer> finishCallback) { + if (action == null) { return null; } Drawable icon = action.getIcon().loadDrawable(context); @@ -382,8 +390,15 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio if (loggingRunnable != null) { loggingRunnable.run(); } + if (shareResultSender != null) { + shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); + } finishCallback.accept(Activity.RESULT_OK); } ); } + + void logCustomAction(int position) { + mLog.logCustomActionSelected(position); + } } diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 039fad56..9643b9f0 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,25 +16,30 @@ 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 android.app.VoiceInteractor.PickOptionRequest.Option; +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; +import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; import static androidx.lifecycle.LifecycleKt.getCoroutineScope; +import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; +import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK; +import static com.android.intentresolver.ui.model.ActivityModel.ACTIVITY_MODEL_KEY; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; -import android.app.Activity; +import static java.util.Objects.requireNonNull; + import android.app.ActivityManager; import android.app.ActivityOptions; +import android.app.ActivityThread; +import android.app.VoiceInteractor; +import android.app.admin.DevicePolicyEventLogger; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; +import android.content.ClipboardManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; @@ -51,29 +56,42 @@ import android.database.Cursor; import android.graphics.Insets; import android.net.Uri; import android.os.Bundle; +import android.os.StrictMode; import android.os.SystemClock; +import android.os.Trace; import android.os.UserHandle; -import android.os.UserManager; import android.service.chooser.ChooserTarget; +import android.stats.devicepolicy.DevicePolicyEnums; +import android.text.TextUtils; import android.util.Log; import android.util.Slog; -import android.util.SparseArray; +import android.view.Gravity; +import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; +import android.view.Window; import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TabHost; +import android.widget.TabWidget; import android.widget.TextView; +import android.widget.Toast; -import androidx.annotation.IntDef; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.CreationExtras; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; +import com.android.intentresolver.ChooserRefinementManager.RefinementType; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -81,49 +99,83 @@ import com.android.intentresolver.contentpreview.BasePreviewViewModel; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.contentpreview.PreviewViewModel; -import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.data.model.ChooserRequest; +import com.android.intentresolver.data.repository.DevicePolicyResources; +import com.android.intentresolver.domain.interactor.UserInteractor; +import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; -import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.DefaultTargetDataLoader; +import com.android.intentresolver.icons.Caching; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.inject.Background; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.measurements.Tracer; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.platform.AppPredictionAvailable; +import com.android.intentresolver.platform.ImageEditor; +import com.android.intentresolver.platform.NearbyShare; +import com.android.intentresolver.profiles.ChooserMultiProfilePagerAdapter; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.profiles.OnProfileSelectedListener; +import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.Profile; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.ui.ActionTitle; +import com.android.intentresolver.ui.ProfilePagerResources; +import com.android.intentresolver.ui.ShareResultSender; +import com.android.intentresolver.ui.ShareResultSenderFactory; +import com.android.intentresolver.ui.model.ActivityModel; +import com.android.intentresolver.ui.viewmodel.ChooserViewModel; +import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView; +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.MetricsEvent; +import com.android.internal.util.LatencyTracker; + +import com.google.common.collect.ImmutableList; import dagger.hilt.android.AndroidEntryPoint; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.text.Collator; +import kotlin.Pair; + +import kotlinx.coroutines.CoroutineDispatcher; + import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; +import java.util.function.Supplier; import javax.inject.Inject; +import javax.inject.Provider; /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. * */ -@AndroidEntryPoint(ResolverActivity.class) +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +@AndroidEntryPoint(FragmentActivity.class) public class ChooserActivity extends Hilt_ChooserActivity implements ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem { private static final String TAG = "ChooserActivity"; @@ -139,7 +191,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /** * Transition name for the first image preview. * To be used for shared element transition into this activity. - * @hide */ public static final String FIRST_IMAGE_PREVIEW_TRANSITION_NAME = "screenshot_preview_image"; @@ -148,6 +199,39 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; private static final String SHORTCUT_TARGET = "shortcut_target"; + ////////////////////////////////////////////////////////////////////////////////////////////// + // Inherited properties. + ////////////////////////////////////////////////////////////////////////////////////////////// + private static final String TAB_TAG_PERSONAL = "personal"; + private static final String TAB_TAG_WORK = "work"; + + private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; + public static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; + + private int mLayoutId; + private UserHandle mHeaderCreatorUser; + private boolean mRegistered; + private PackageMonitor mPersonalPackageMonitor; + private PackageMonitor mWorkPackageMonitor; + + protected ResolverDrawerLayout mResolverDrawerLayout; + private TabHost mTabHost; + private ResolverViewPager mViewPager; + protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); + + /** See {@link #setRetainInOnStop}. */ + private boolean mRetainInOnStop; + protected Insets mSystemWindowInsets = null; + private ResolverActivity.PickTargetOptionRequest mPickOptionRequest; + + @Nullable + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + + ////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + + // 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`. @@ -156,37 +240,41 @@ public class ChooserActivity extends Hilt_ChooserActivity implements 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; + static final int TARGET_TYPE_DEFAULT = 0; + static final int TARGET_TYPE_CHOOSER_TARGET = 1; + static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; + static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; 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; - @IntDef({ - TARGET_TYPE_DEFAULT, - TARGET_TYPE_CHOOSER_TARGET, - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE - }) - @Retention(RetentionPolicy.SOURCE) - public @interface ShareTargetType {} - + @Inject public UserInteractor mUserInteractor; + @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; + @Inject public ChooserHelper mChooserHelper; @Inject public FeatureFlags mFeatureFlags; + @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @Inject public EventLog mEventLog; - - private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; - - /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the - * only assignment there, and expect it to be ready by the time we ever use it -- - * someday if we move all the usage to a component with a narrower lifecycle (something that - * 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; + @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; + @Inject @ImageEditor public Optional<ComponentName> mImageEditor; + @Inject @NearbyShare public Optional<ComponentName> mNearbyShare; + protected TargetDataLoader mTargetDataLoader; + @Inject public Provider<TargetDataLoader> mTargetDataLoaderProvider; + @Inject + @Caching + public Provider<TargetDataLoader> mCachingTargetDataLoaderProvider; + @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public ProfilePagerResources mProfilePagerResources; + @Inject public PackageManager mPackageManager; + @Inject public ClipboardManager mClipboardManager; + @Inject public IntentForwarding mIntentForwarding; + @Inject public ShareResultSenderFactory mShareResultSenderFactory; + + private ActivityModel mActivityModel; + private ChooserRequest mRequest; + private ProfileHelper mProfiles; + private ProfileAvailability mProfileAvailability; + @Nullable private ShareResultSender mShareResultSender; private ChooserRefinementManager mRefinementManager; @@ -214,14 +302,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private int mScrollStatus = SCROLL_STATUS_IDLE; - @VisibleForTesting - protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); - private View mContentView = null; - - private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>(); + private final Map<Integer, ProfileRecord> mProfileRecords = new HashMap<>(); private boolean mExcludeSharedText = false; /** @@ -232,109 +316,352 @@ public class ChooserActivity extends Hilt_ChooserActivity implements */ private boolean mFinishWhenStopped = false; + private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); + + protected ActivityModel createActivityModel() { + return ActivityModel.createFrom(this); + } + + private ChooserViewModel mViewModel; + + @NonNull + @Override + public CreationExtras getDefaultViewModelCreationExtras() { + return addDefaultArgs( + super.getDefaultViewModelCreationExtras(), + new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel())); + } + @Override protected void onCreate(Bundle savedInstanceState) { - Tracer.INSTANCE.markLaunched(); - final long intentReceivedTime = System.currentTimeMillis(); - mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + super.onCreate(savedInstanceState); + Log.i(TAG, "onCreate"); - try { - mChooserRequest = new ChooserRequestParameters( - getIntent(), - getReferrerPackageName(), - getReferrer()); - } catch (IllegalArgumentException e) { - Log.e(TAG, "Caller provided invalid Chooser request parameters", e); + mTargetDataLoader = mChooserServiceFeatureFlags.chooserPayloadToggling() + ? mCachingTargetDataLoaderProvider.get() + : mTargetDataLoaderProvider.get(); + + setTheme(R.style.Theme_DeviceDefault_Chooser); + + // Initializer is invoked when this function returns, via Lifecycle. + mChooserHelper.setInitializer(this::initialize); + if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { + mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); + mChooserHelper.setOnPendingSelection(this::onPendingSelection); + } + } + + @Override + protected final void onStart() { + super.onStart(); + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + } + + @Override + protected final void onResume() { + super.onResume(); + Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); + mFinishWhenStopped = false; + mRefinementManager.onActivityResume(); + } + + @Override + protected final void onStop() { + super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mRetainInOnStop) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } + + if (mRefinementManager != null) { + mRefinementManager.onActivityStop(isChangingConfigurations()); + } + + if (mFinishWhenStopped) { + mFinishWhenStopped = false; finish(); - super_onCreate(null); - return; } + } + + @Override + protected final void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (mViewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, mViewPager.getCurrentItem()); + } + } + + @Override + protected final void onRestart() { + super.onRestart(); + if (mFeatureFlags.fixPrivateSpaceLockedOnRestart()) { + if (mChooserMultiProfilePagerAdapter.hasPageForProfile(Profile.Type.PRIVATE.ordinal()) + && !mProfileAvailability.isAvailable(mProfiles.getPrivateProfile())) { + Log.d(TAG, "Exiting due to unavailable profile"); + finish(); + return; + } + } + + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + mRegistered = true; + } + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mChooserMultiProfilePagerAdapter != null) { + mChooserMultiProfilePagerAdapter.destroy(); + } + + if (isFinishing()) { + mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); + } + + mBackgroundThreadPoolExecutor.shutdownNow(); + + destroyProfileRecords(); + } + + /** DO NOT CALL. Only for use from ChooserHelper as a callback. */ + private void initialize() { + + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); + mRequest = mViewModel.getRequest().getValue(); + mActivityModel = mViewModel.getActivityModel(); + + mProfiles = new ProfileHelper( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher, + mFeatureFlags); + + mProfileAvailability = new ProfileAvailability( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher); + + mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); + + mIntentReceivedTime.set(System.currentTimeMillis()); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + mPinnedSharedPrefs = getPinnedSharedPrefs(this); - mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + updateShareResultSender(); + + mMaxTargetsPerRow = + getResources().getInteger(R.integer.config_chooser_max_targets_per_row); mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); - setRetainInOnStop(mChooserRequest.shouldRetainInOnStop()); + setRetainInOnStop(mRequest.shouldRetainInOnStop()); createProfileRecords( new AppPredictorFactory( this, - mChooserRequest.getSharedText(), - mChooserRequest.getTargetIntentFilter(), - getPackageManager().getAppPredictionServicePackageName() != null), - mChooserRequest.getTargetIntentFilter()); - - - super.onCreate( - savedInstanceState, - mChooserRequest.getTargetIntent(), - mChooserRequest.getAdditionalTargets(), - mChooserRequest.getTitle(), - mChooserRequest.getDefaultTitleResource(), - mChooserRequest.getInitialIntents(), - /* resolutionList= */ null, - /* supportsAlwaysUseOption= */ false, - new DefaultTargetDataLoader(this, getLifecycle(), false), - /* safeForwardingMode= */ true); + Objects.toString(mRequest.getSharedText(), null), + mRequest.getShareTargetFilter(), + mAppPredictionAvailable + ), + mRequest.getShareTargetFilter() + ); - getEventLog().logSharesheetTriggered(); - mIntegratedDeviceComponents = getIntegratedDeviceComponents(); + mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + /* context = */ this, + mProfilePagerResources, + mRequest, + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow); + + maybeDisableRecentsScreenshot(mProfiles, mProfileAvailability); + + if (!configureContentView(mTargetDataLoader)) { + mPersonalPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false + ); + if (mProfiles.getWorkProfilePresent()) { + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false + ); + } + mRegistered = true; + final ResolverDrawerLayout rdl = findViewById( + com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { + @Override + public void onDismissed() { + finish(); + } + }); + + boolean hasTouchScreen = mPackageManager + .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + if (isVoiceInteraction() || !hasTouchScreen) { + rdl.setCollapsed(false); + } + + rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); + mResolverDrawerLayout = rdl; + } + + Intent intent = mRequest.getTargetIntent(); + final Set<String> categories = intent.getCategories(); + MetricsLogger.action(this, + mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED + : MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, + intent.getAction() + ":" + intent.getType() + ":" + + (categories != null ? Arrays.toString(categories.toArray()) + : "")); + } + + getEventLog().logSharesheetTriggered(); + mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); mRefinementManager.getRefinementCompletion().observe(this, completion -> { if (completion.consume()) { - TargetInfo targetInfo = completion.getTargetInfo(); - // targetInfo is non-null if the refinement process was successful. - if (targetInfo != null) { - maybeRemoveSharedText(targetInfo); - - // We already block suspended targets from going to refinement, and we probably - // can't recover a Chooser session if that's the reason the refined target fails - // to launch now. Fire-and-forget the refined launch; ignore the return value - // and just make sure the Sharesheet session gets cleaned up regardless. - ChooserActivity.super.onTargetSelected(targetInfo, false); + if (completion.getRefinedIntent() == null) { + finish(); + return; + } + + // Prepare to regenerate our "system actions" based on the refined intent. + // TODO: optimize if needed. `TARGET_INFO` cases don't require a new action + // factory at all. And if we break up `ChooserActionFactory`, we could avoid + // resolving a new editor intent unless we're handling an `EDIT_ACTION`. + ChooserActionFactory refinedActionFactory = + createChooserActionFactory(completion.getRefinedIntent()); + switch (completion.getType()) { + case TARGET_INFO: { + TargetInfo refinedTarget = completion + .getOriginalTargetInfo() + .tryToCloneWithAppliedRefinement( + completion.getRefinedIntent()); + if (refinedTarget == null) { + Log.e(TAG, "Failed to apply refinement to any matching source intent"); + } else { + maybeRemoveSharedText(refinedTarget); + + // We already block suspended targets from going to refinement, and we + // probably can't recover a Chooser session if that's the reason the + // refined target fails to launch now. Fire-and-forget the refined + // launch, and make sure Sharesheet gets cleaned up regardless of the + // outcome of that launch.launch; ignore + + safelyStartActivity(refinedTarget); + } + } + break; + + case COPY_ACTION: { + if (refinedActionFactory.getCopyButtonRunnable() != null) { + refinedActionFactory.getCopyButtonRunnable().run(); + } + } + break; + + case EDIT_ACTION: { + if (refinedActionFactory.getEditButtonRunnable() != null) { + refinedActionFactory.getEditButtonRunnable().run(); + } + } + break; } finish(); } }); - BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); previewViewModel.init( - mChooserRequest.getTargetIntent(), - getIntent(), - /*additionalContentUri = */ null, - /*focusedItemIdx = */ 0, - /*isPayloadTogglingEnabled = */ false); + mRequest.getTargetIntent(), + mRequest.getAdditionalContentUri(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); + ChooserContentPreviewUi.ActionFactory actionFactory = + decorateActionFactoryWithRefinement( + createChooserActionFactory(mRequest.getTargetIntent())); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), previewViewModel.getPreviewDataProvider(), - mChooserRequest.getTargetIntent(), + mRequest.getTargetIntent(), previewViewModel.getImageLoader(), - createChooserActionFactory(), + actionFactory, + createModifyShareActionFactory(), mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), - ContentTypeHint.NONE, - mChooserRequest.getMetadataText(), - /*isPayloadTogglingEnabled =*/ false - ); - + mRequest.getContentTypeHint(), + mRequest.getMetadataText(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); updateStickyContentPreview(); - if (shouldShowStickyContentPreview() - || mChooserMultiProfilePagerAdapter - .getCurrentRootAdapter().getSystemRowCount() != 0) { + if (shouldShowStickyContentPreview()) { getEventLog().logActionShareWithPreview( mChooserContentPreviewUi.getPreferredContentPreview()); } - mChooserShownTime = System.currentTimeMillis(); - final long systemCost = mChooserShownTime - intentReceivedTime; + final long systemCost = mChooserShownTime - mIntentReceivedTime.get(); getEventLog().logChooserActivityShown( - isWorkProfile(), mChooserRequest.getTargetType(), systemCost); - + isWorkProfile(), mRequest.getTargetType(), systemCost); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); @@ -344,49 +671,690 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog().logSharesheetExpansionChanged(isCollapsed); }); } - if (DEBUG) { Log.d(TAG, "System Time Cost is " + systemCost); } - getEventLog().logShareStarted( - getReferrerPackageName(), - mChooserRequest.getTargetType(), - mChooserRequest.getCallerChooserTargets().size(), - (mChooserRequest.getInitialIntents() == null) - ? 0 : mChooserRequest.getInitialIntents().length, + mRequest.getReferrerPackage(), + mRequest.getTargetType(), + mRequest.getCallerChooserTargets().size(), + mRequest.getInitialIntents().size(), isWorkProfile(), mChooserContentPreviewUi.getPreferredContentPreview(), - mChooserRequest.getTargetAction(), - mChooserRequest.getChooserActions().size(), - mChooserRequest.getModifyShareAction() != null + mRequest.getTargetAction(), + mRequest.getChooserActions().size(), + mRequest.getModifyShareAction() != null ); - mEnterTransitionAnimationDelegate.postponeTransition(); + Tracer.INSTANCE.markLaunched(); } - @VisibleForTesting - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return ChooserIntegratedDeviceComponents.get(this, new SecureSettings()); + private void maybeDisableRecentsScreenshot( + ProfileHelper profileHelper, ProfileAvailability profileAvailability) { + for (Profile profile : profileHelper.getProfiles()) { + if (profile.getType() == Profile.Type.PRIVATE) { + if (profileAvailability.isAvailable(profile)) { + // Show blank screen in Recent preview if private profile is available + // to not leak its presence. + setRecentsScreenshotEnabled(false); + } + return; + } + } + } + + private void onChooserRequestChanged(ChooserRequest chooserRequest) { + // intentional reference comparison + if (mRequest == chooserRequest) { + return; + } + boolean recreateAdapters = shouldUpdateAdapters(mRequest, chooserRequest); + mRequest = chooserRequest; + updateShareResultSender(); + mChooserContentPreviewUi.updateModifyShareAction(); + if (recreateAdapters) { + recreatePagerAdapter(); + } else { + setTabsViewEnabled(true); + } + } + + private void onPendingSelection() { + setTabsViewEnabled(false); + } + + private void onAppTargetsLoaded(ResolverListAdapter listAdapter) { + if (mChooserMultiProfilePagerAdapter == null) { + return; + } + if (!isProfilePagerAdapterAttached() + && listAdapter == mChooserMultiProfilePagerAdapter.getActiveListAdapter()) { + mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); + setTabsViewEnabled(true); + } + } + + private void updateShareResultSender() { + IntentSender chosenComponentSender = mRequest.getChosenComponentSender(); + if (chosenComponentSender != null) { + mShareResultSender = mShareResultSenderFactory.create( + mViewModel.getActivityModel().getLaunchedFromUid(), chosenComponentSender); + } else { + mShareResultSender = null; + } + } + + private boolean shouldUpdateAdapters( + ChooserRequest oldChooserRequest, ChooserRequest newChooserRequest) { + Intent oldTargetIntent = oldChooserRequest.getTargetIntent(); + Intent newTargetIntent = newChooserRequest.getTargetIntent(); + List<Intent> oldAltIntents = oldChooserRequest.getAdditionalTargets(); + List<Intent> newAltIntents = newChooserRequest.getAdditionalTargets(); + + // TODO: a workaround for the unnecessary target reloading caused by multiple flow updates - + // an artifact of the current implementation; revisit. + return !oldTargetIntent.equals(newTargetIntent) || !oldAltIntents.equals(newAltIntents); + } + + private void recreatePagerAdapter() { + if (!mChooserServiceFeatureFlags.chooserPayloadToggling()) { + return; + } + destroyProfileRecords(); + createProfileRecords( + new AppPredictorFactory( + this, + Objects.toString(mRequest.getSharedText(), null), + mRequest.getShareTargetFilter(), + mAppPredictionAvailable + ), + mRequest.getShareTargetFilter() + ); + + int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); + if (mChooserMultiProfilePagerAdapter != null) { + mChooserMultiProfilePagerAdapter.destroy(); + } + // Update the pager adapter but do not attach it to the view till the targets are reloaded, + // see onChooserAppTargetsLoaded method. + mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + /* context = */ this, + mProfilePagerResources, + mRequest, + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow); + mChooserMultiProfilePagerAdapter.setCurrentPage(currentPage); + for (int i = 0, count = mChooserMultiProfilePagerAdapter.getItemCount(); i < count; i++) { + mChooserMultiProfilePagerAdapter.getPageAdapterForIndex(i) + .getListAdapter().setAnimateItems(false); + } + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + mPersonalPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + postRebuildList( + mChooserMultiProfilePagerAdapter.rebuildTabs( + mProfiles.getWorkProfilePresent() || mProfiles.getPrivateProfilePresent())); + setTabsViewEnabled(false); + } + + private void setTabsViewEnabled(boolean isEnabled) { + TabWidget tabs = mTabHost.getTabWidget(); + if (tabs != null) { + tabs.setEnabled(isEnabled); + } + View tabContent = mTabHost.findViewById(com.android.internal.R.id.profile_pager); + if (tabContent != null) { + tabContent.setEnabled(isEnabled); + } } @Override - protected int appliedThemeResId() { - return R.style.Theme_DeviceDefault_Chooser; + protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { + if (mViewPager != null) { + mViewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // Inherited methods + ////////////////////////////////////////////////////////////////////////////////////////////// + + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + + private boolean maybeAutolaunchIfSingleTarget() { + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + if (count != 1) { + return false; + } + + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { + return false; + } + + // Only one target, so we're a candidate to auto-launch! + final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(0, false); + if (shouldAutoLaunchSingleChoice(target)) { + safelyStartActivity(target); + finish(); + return true; + } + return false; + } + + private boolean isTwoPagePersonalAndWorkConfiguration() { + return (mChooserMultiProfilePagerAdapter.getCount() == 2) + && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) + && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); + } + + /** + * When we have a personal and a work profile, we auto launch in the following scenario: + * - There is 1 resolved target on each profile + * - That target is the same app on both profiles + * - The target app has permission to communicate cross profiles + * - The target app has declared it supports cross-profile communication via manifest metadata + */ + private boolean maybeAutolaunchIfCrossProfileSupported() { + if (!isTwoPagePersonalAndWorkConfiguration()) { + return false; + } + + ResolverListAdapter activeListAdapter = + (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mChooserMultiProfilePagerAdapter.getPersonalListAdapter() + : mChooserMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveListAdapter = + (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mChooserMultiProfilePagerAdapter.getWorkListAdapter() + : mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; + } + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } + + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } + + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } + + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { + return false; + } + + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(mProfiles.getPersonalHandle())) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; + } + + /** + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} + */ + private boolean maybeAutolaunchActivity() { + int numberOfProfiles = mChooserMultiProfilePagerAdapter.getItemCount(); + // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the + // correct intent-picker UIs (e.g., mini-resolver) if it was launched without + // ACTION_SEND. + if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { + return true; + } else if (maybeAutolaunchIfCrossProfileSupported()) { + return true; + } + return false; + } + + @Override // ResolverListCommunicator + public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, + boolean rebuildCompleted) { + if (isAutolaunching()) { + return; + } + if (mChooserMultiProfilePagerAdapter + .shouldShowEmptyStateScreen((ChooserListAdapter) listAdapter)) { + mChooserMultiProfilePagerAdapter + .showEmptyResolverListEmptyState((ChooserListAdapter) listAdapter); + } else { + mChooserMultiProfilePagerAdapter.showListView((ChooserListAdapter) listAdapter); + } + // showEmptyResolverListEmptyState can mark the tab as loaded, + // which is a precondition for auto launching + if (rebuildCompleted && maybeAutolaunchActivity()) { + return; + } + if (doPostProcessing) { + maybeCreateHeader(listAdapter); + onListRebuilt(listAdapter, rebuildCompleted); + } + } + + private CharSequence getOrLoadDisplayLabel(TargetInfo info) { + if (info.isDisplayResolveInfo()) { + mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); + } + CharSequence displayLabel = info.getDisplayLabel(); + return displayLabel == null ? "" : displayLabel; + } + + protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { + final ActionTitle title = ActionTitle.forAction(intent.getAction()); + + // While there may already be a filtered item, we can only use it in the title if the list + // is already sorted and all information relevant to it is already in the list. + final boolean named = + mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; + if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { + return getString(defaultTitleRes); + } else { + return named + ? getString( + title.namedTitleRes, + getOrLoadDisplayLabel( + mChooserMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem())) + : getString(title.titleRes); + } + } + + /** + * Configure the area above the app selection list (title, content preview, etc). + */ + private void maybeCreateHeader(ResolverListAdapter listAdapter) { + if (mHeaderCreatorUser != null + && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { + return; + } + if (!mProfiles.getWorkProfilePresent() + && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setVisibility(View.GONE); + } + } + + CharSequence title = mRequest.getTitle() != null + ? mRequest.getTitle() + : getTitleForAction(mRequest.getTargetIntent(), + mRequest.getDefaultTitleResource()); + + if (!TextUtils.isEmpty(title)) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setText(title); + } + setTitle(title); + } + + final ImageView iconView = findViewById(com.android.internal.R.id.icon); + if (iconView != null) { + listAdapter.loadFilteredItemIconTaskAsync(iconView); + } + mHeaderCreatorUser = listAdapter.getUserHandle(); + } + + /** Start the activity specified by the {@link TargetInfo}.*/ + public final void safelyStartActivity(TargetInfo cti) { + // In case cloned apps are present, we would want to start those apps in cloned user + // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle + // identifies the correct user space in such cases. + UserHandle activityUserHandle = cti.getResolveInfo().userHandle; + safelyStartActivityAsUser(cti, activityUserHandle, null); + } + + protected final void safelyStartActivityAsUser( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // We're dispatching intents that might be coming from legacy apps, so + // don't kill ourselves. + StrictMode.disableDeathOnFileUriExposure(); + try { + safelyStartActivityInternal(cti, user, options); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } + + @VisibleForTesting + protected void safelyStartActivityInternal( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // If the target is suspended, the activity will not be successfully launched. + // Do not unregister from package manager updates in this case + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + String profileSwitchMessage = mIntentForwarding.forwardMessageFor( + mRequest.getTargetIntent()); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + maybeSendShareResult(cti); + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + mActivityModel.getLaunchedFromUid() + + " package " + mActivityModel.getLaunchedFromPackage() + + ", while running in " + ActivityThread.currentProcessName(), e); + } + } + + private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { + if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { + return; + } + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) + .setBoolean(currentUserHandle.equals(mProfiles.getPersonalHandle())) + .setStrings(getMetricsCategory(), + cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") + .write(); + } + + private LatencyTracker getLatencyTracker() { + return LatencyTracker.getInstance(this); + } + + /** + * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets + * called and we are launched in a new task. + */ + protected final void setRetainInOnStop(boolean retainInOnStop) { + mRetainInOnStop = retainInOnStop; + } + + // @NonFinalForTesting + @VisibleForTesting + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + return new CrossProfileIntentsChecker(getContentResolver()); + } + + protected final EmptyStateProvider createEmptyStateProvider( + ProfileHelper profileHelper, + ProfileAvailability profileAvailability) { + EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); + + EmptyStateProvider workProfileOffEmptyStateProvider = + new WorkProfilePausedEmptyStateProvider( + this, + profileHelper, + profileAvailability, + /* onSwitchOnWorkSelectedListener = */ + () -> { + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + }, + getMetricsCategory()); + + EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + mProfiles, + mProfileAvailability, + getMetricsCategory(), + mProfilePagerResources + ); + + // Return composite provider, the order matters (the higher, the more priority) + return new CompositeEmptyStateProvider( + blockerEmptyStateProvider, + workProfileOffEmptyStateProvider, + noAppsEmptyStateProvider + ); + } + + /** + * Returns the {@link List} of {@link UserHandle} to pass on to the + * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. + */ + private List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) { + return getResolverRankerServiceUserHandleListInternal(userHandle); + } + + private List<UserHandle> getResolverRankerServiceUserHandleListInternal(UserHandle userHandle) { + List<UserHandle> userList = new ArrayList<>(); + userList.add(userHandle); + // Add clonedProfileUserHandle to the list only if we are: + // a. Building the Personal Tab. + // b. CloneProfile exists on the device. + if (userHandle.equals(mProfiles.getPersonalHandle()) + && mProfiles.getCloneUserPresent()) { + userList.add(mProfiles.getCloneHandle()); + } + return userList; + } + + /** + * Start activity as a fixed user handle. + * @param cti TargetInfo to be launched. + * @param user User to launch this activity as. + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) + public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { + safelyStartActivityAsUser(cti, user, null); + } + + @Override // ResolverListCommunicator + public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( + (ChooserListAdapter) listAdapter, + mProfileAvailability.getWaitingToEnableProfile())) { + // We no longer have any items... just finish the activity. + finish(); + } + } + + final Option optionForChooserTarget(TargetInfo target, int index) { + return new Option(getOrLoadDisplayLabel(target), index); + } + + @Override // ResolverListCommunicator + public final void sendVoiceChoicesIfNeeded() { + if (!isVoiceInteraction()) { + // Clearly not needed. + return; + } + + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount(); + final Option[] options = new Option[count]; + for (int i = 0; i < options.length; i++) { + TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); + if (target == null) { + // If this occurs, a new set of targets is being loaded. Let that complete, + // and have the next call to send voice choices proceed instead. + return; + } + options[i] = optionForChooserTarget(target, i); + } + + mPickOptionRequest = new ResolverActivity.PickTargetOptionRequest( + new VoiceInteractor.Prompt(getTitle()), options, null); + getVoiceInteractor().submitRequest(mPickOptionRequest); + } + + /** + * Sets up the content view. + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + private boolean configureContentView(TargetDataLoader targetDataLoader) { + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) { + throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " + + "cannot be null."); + } + Trace.beginSection("configureContentView"); + // We partially rebuild the inactive adapter to determine if we should auto launch + // isTabLoaded will be true here if the empty state screen is shown instead of the list. + boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs( + mProfiles.getWorkProfilePresent()); + + mLayoutId = R.layout.chooser_grid_scrollable_preview; + + setContentView(mLayoutId); + mTabHost = findViewById(com.android.internal.R.id.profile_tabhost); + mViewPager = requireViewById(com.android.internal.R.id.profile_pager); + mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); + boolean result = postRebuildList(rebuildCompleted); + Trace.endSection(); + return result; + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList. + * @param rebuildCompleted + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + protected boolean postRebuildList(boolean rebuildCompleted) { + return postRebuildListInternal(rebuildCompleted); + } + + /** + * Add a label to signify that the user can pick a different app. + * @param adapter The adapter used to provide data to item views. + */ + public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { + final boolean useHeader = adapter.hasFilteredItem(); + if (useHeader) { + FrameLayout stub = findViewById(com.android.internal.R.id.stub); + stub.setVisibility(View.VISIBLE); + TextView textView = (TextView) LayoutInflater.from(this).inflate( + R.layout.resolver_different_item_header, null, false); + if (mProfiles.getWorkProfilePresent()) { + textView.setGravity(Gravity.CENTER); + } + stub.addView(textView); + } + } + private void setupViewVisibilities() { + ChooserListAdapter activeListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + if (!mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { + addUseDifferentAppLabelIfNecessary(activeListAdapter); + } + } + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; + } + + setupViewVisibilities(); + + if (mProfiles.getWorkProfilePresent() + || (mProfiles.getPrivateProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getPrivateProfile())))) { + setupProfileTabs(); + } + + return false; + } + + private void setupProfileTabs() { + mChooserMultiProfilePagerAdapter.setupProfileTabs( + getLayoutInflater(), + mTabHost, + mViewPager, + R.layout.resolver_profile_tab_button, + com.android.internal.R.id.profile_pager, + () -> onProfileTabSelected(mViewPager.getCurrentItem()), + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {} + + @Override + public void onProfilePageStateChanged(int state) { + onHorizontalSwipeStateChanged(state); + } + }); + mOnSwitchOnWorkSelectedListener = () -> { + View workTab = mTabHost.getTabWidget().getChildAt( + mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; } + ////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = getAnnotatedUserHandles().personalProfileUserHandle; + UserHandle mainUserHandle = mProfiles.getPersonalHandle(); ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); if (record.shortcutLoader == null) { Tracer.INSTANCE.endLaunchToShortcutTrace(); } - UserHandle workUserHandle = getAnnotatedUserHandles().workProfileUserHandle; + UserHandle workUserHandle = mProfiles.getWorkHandle(); if (workUserHandle != null) { createProfileRecord(workUserHandle, targetIntentFilter, factory); } + + UserHandle privateUserHandle = mProfiles.getPrivateHandle(); + if (privateUserHandle != null && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getPrivateProfile()))) { + createProfileRecord(privateUserHandle, targetIntentFilter, factory); + } } private ProfileRecord createProfileRecord( @@ -407,7 +1375,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Nullable private ProfileRecord getProfileRecord(UserHandle userHandle) { - return mProfileRecords.get(userHandle.getIdentifier(), null); + return mProfileRecords.get(userHandle.getIdentifier()); } @VisibleForTesting @@ -430,126 +1398,70 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); } - @Override - protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - if (shouldShowTabs()) { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed, targetDataLoader); - } else { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed, targetDataLoader); - } - 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( - getAnnotatedUserHandles().personalProfileUserHandle, - noWorkToPersonalEmptyState, - noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } + private ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( + Context context, + ProfilePagerResources profilePagerResources, + ChooserRequest request, + ProfileHelper profileHelper, + ProfileAvailability profileAvailability, + List<Intent> initialIntents, + int maxTargetsPerRow) { + Log.d(TAG, "createMultiProfilePagerAdapter"); + + Profile launchedAs = profileHelper.getLaunchedAsProfile(); + + Intent[] initialIntentArray = initialIntents.toArray(new Intent[0]); + List<Intent> payloadIntents = request.getPayloadIntents(); + + List<TabConfig<ChooserGridAdapter>> tabs = new ArrayList<>(); + for (Profile profile : profileHelper.getProfiles()) { + if (profile.getType() == Profile.Type.PRIVATE + && !profileAvailability.isAvailable(profile)) { + continue; + } + ChooserGridAdapter adapter = createChooserGridAdapter( + context, + payloadIntents, + profile.equals(launchedAs) ? initialIntentArray : null, + profile.getPrimary().getHandle() + ); + tabs.add(new TabConfig<>( + /* profile = */ profile.getType().ordinal(), + profilePagerResources.profileTabLabel(profile.getType()), + profilePagerResources.profileTabAccessibilityLabel(profile.getType()), + /* tabTag = */ profile.getType().name(), + adapter)); + } + + EmptyStateProvider emptyStateProvider = + createEmptyStateProvider(profileHelper, profileAvailability); + + Supplier<Boolean> workProfileQuietModeChecker = + () -> !(profileHelper.getWorkProfilePresent() + && profileAvailability.isAvailable( + requireNonNull(profileHelper.getWorkProfile()))); - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - ChooserGridAdapter adapter = createChooserGridAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - initialIntents, - rList, - filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, - adapter, - createEmptyStateProvider(/* workProfileUserHandle= */ null), - /* workProfileQuietModeChecker= */ () -> false, - /* workProfileUserHandle= */ null, - getAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow, - mFeatureFlags); + ImmutableList.copyOf(tabs), + emptyStateProvider, + workProfileQuietModeChecker, + launchedAs.getType().ordinal(), + profileHelper.getWorkHandle(), + profileHelper.getCloneHandle(), + maxTargetsPerRow); } - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - int selectedProfile = findSelectedProfile(); - ChooserGridAdapter personalAdapter = createChooserGridAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); - ChooserGridAdapter workAdapter = createChooserGridAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - selectedProfile == PROFILE_WORK ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().workProfileUserHandle, - targetDataLoader); - return new ChooserMultiProfilePagerAdapter( - /* context */ this, - personalAdapter, - workAdapter, - createEmptyStateProvider(getAnnotatedUserHandles().workProfileUserHandle), - () -> mWorkProfileAvailability.isQuietModeEnabled(), - selectedProfile, - getAnnotatedUserHandles().workProfileUserHandle, - getAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow, - mFeatureFlags); + protected EmptyStateProvider createBlockerEmptyStateProvider() { + return new NoCrossProfileEmptyStateProvider( + mProfiles, + mDevicePolicyResources, + createCrossProfileIntentsChecker(), + mRequest.isSendActionTarget()); } private int findSelectedProfile() { - int selectedProfile = getSelectedProfileExtra(); - if (selectedProfile == -1) { - selectedProfile = getProfileForUser( - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - return selectedProfile; + return mProfiles.getLaunchedAsProfileType().ordinal(); } /** @@ -557,12 +1469,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return true if it is work profile, false if it is parent profile (or no work profile is * set up) */ - protected boolean isWorkProfile() { - return getSystemService(UserManager.class) - .getUserInfo(UserHandle.myUserId()).isManagedProfile(); + private boolean isWorkProfile() { + return mProfiles.getLaunchedAsProfileType() == Profile.Type.WORK; } - @Override + //@Override protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override @@ -589,39 +1500,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // Refresh pinned items mPinnedSharedPrefs = getPinnedSharedPrefs(this); if (listAdapter == null) { - handlePackageChangePerProfile(mChooserMultiProfilePagerAdapter.getActiveListAdapter()); - if (mChooserMultiProfilePagerAdapter.getCount() > 1) { - handlePackageChangePerProfile( - mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); - } + mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs(); } else { - handlePackageChangePerProfile(listAdapter); + listAdapter.handlePackagesChanged(); } - updateProfileViewButton(); - } - - private void handlePackageChangePerProfile(ResolverListAdapter adapter) { - ProfileRecord record = getProfileRecord(adapter.getUserHandle()); - if (record != null && record.shortcutLoader != null) { - record.shortcutLoader.reset(); - } - adapter.handlePackagesChanged(); } @Override - protected void onResume() { - super.onResume(); - Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); - mFinishWhenStopped = false; - mRefinementManager.onActivityResume(); - } - - @Override - public void onConfigurationChanged(@NonNull Configuration newConfig) { + public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager.isLayoutRtl()) { - mMultiProfilePagerAdapter.setupViewPager(viewPager); + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + + if (mSystemWindowInsets != null) { + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + } + if (mViewPager.isLayoutRtl()) { + mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); } mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); @@ -651,7 +1546,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void updateTabPadding() { - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { View tabs = findViewById(com.android.internal.R.id.tabs); float iconSize = getResources().getDimension(R.dimen.chooser_icon_size); // The entire width consists of icons or padding. Divide the item padding in half to get @@ -683,9 +1578,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getResources(), getLayoutInflater(), parent, - mFeatureFlags.scrollablePreview() - ? findViewById(R.id.chooser_headline_row_container) - : null); + requireViewById(R.id.chooser_headline_row_container)); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); @@ -711,47 +1604,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return resolver.query(uri, null, null, null, null); } - @Override - protected void onStop() { - super.onStop(); - mRefinementManager.onActivityStop(isChangingConfigurations()); - - if (mFinishWhenStopped) { - mFinishWhenStopped = false; - finish(); - } - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (isFinishing()) { - mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); - } - - mBackgroundThreadPoolExecutor.shutdownNow(); - - destroyProfileRecords(); - } - private void destroyProfileRecords() { - for (int i = 0; i < mProfileRecords.size(); ++i) { - mProfileRecords.valueAt(i).destroy(); - } + mProfileRecords.values().forEach(ProfileRecord::destroy); mProfileRecords.clear(); } @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - if (mChooserRequest == null) { - return defIntent; - } - Intent result = defIntent; - if (mChooserRequest.getReplacementExtras() != null) { + if (mRequest.getReplacementExtras() != null) { final Bundle replExtras = - mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName); + mRequest.getReplacementExtras().getBundle(aInfo.packageName); if (replExtras != null) { result = new Intent(defIntent); result.putExtras(replExtras); @@ -770,33 +1633,22 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return result; } - @Override - public void onActivityStarted(TargetInfo cti) { - if (mChooserRequest.getChosenComponentSender() != null) { + private void maybeSendShareResult(TargetInfo cti) { + if (mShareResultSender != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { - final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); - try { - 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); - } + mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo()); } } } private void addCallerChooserTargets() { - if (!mChooserRequest.getCallerChooserTargets().isEmpty()) { + if (!mRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. - UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) - ? getAnnotatedUserHandles().workProfileUserHandle - : getAnnotatedUserHandles().personalProfileUserHandle; - if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { + if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, - new ArrayList<>(mChooserRequest.getCallerChooserTargets()), + new ArrayList<>(mRequest.getCallerChooserTargets()), TARGET_TYPE_DEFAULT, /* directShareShortcutInfoCache */ Collections.emptyMap(), /* directShareAppTargetCache */ Collections.emptyMap()); @@ -804,28 +1656,19 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override - public int getLayoutResource() { - return mFeatureFlags.scrollablePreview() - ? R.layout.chooser_grid_scrollable_preview - : R.layout.chooser_grid; - } - @Override // ResolverListCommunicator public boolean shouldGetActivityMetadata() { return true; } - @Override public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - // Note that this is only safe because the Intent handled by the ChooserActivity is - // guaranteed to contain no extras unknown to the local ClassLoader. That is why this - // method can not be replaced in the ResolverActivity whole hog. - if (!super.shouldAutoLaunchSingleChoice(target)) { + if (target.isSuspended()) { return false; } - return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); + // TODO: migrate to ChooserRequest + return mViewModel.getActivityModel().getIntent() + .getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); } private void showTargetDetails(TargetInfo targetInfo) { @@ -840,8 +1683,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // 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; + IntentFilter intentFilter; + intentFilter = targetInfo.isSelectableTargetInfo() + ? mRequest.getShareTargetFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; String shortcutIdKey = targetInfo.getDirectShareShortcutId(); @@ -858,22 +1702,25 @@ public class ChooserActivity extends Hilt_ChooserActivity implements intentFilter); } - @Override - protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { + protected boolean onTargetSelected(TargetInfo target) { if (mRefinementManager.maybeHandleSelection( target, - mChooserRequest.getRefinementIntentSender(), + mRequest.getRefinementIntentSender(), getApplication(), getMainThreadHandler())) { return false; } updateModelAndChooserCounts(target); maybeRemoveSharedText(target); - return super.onTargetSelected(target, alwaysCheck); + safelyStartActivity(target); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + return !target.isSuspended(); } @Override - public void startSelected(int which, boolean always, boolean filtered) { + public void startSelected(int which, /* unused */ boolean always, boolean filtered) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); TargetInfo targetInfo = currentListAdapter @@ -896,8 +1743,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return; } } + if (isFinishing()) { + return; + } - super.startSelected(which, always, filtered); + TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(which, filtered); + if (target != null) { + if (onTargetSelected(target)) { + MetricsLogger.action( + this, MetricsEvent.ACTION_APP_DISAMBIG_TAP); + MetricsLogger.action(this, + mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED + : MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + finish(); + } + } // TODO: both of the conditions around this switch logic *should* be redundant, and // can be removed if certain invariants can be guaranteed. In particular, it seems @@ -917,7 +1779,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetInfo.getResolveInfo().activityInfo.processName, which, /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - mChooserRequest.getCallerChooserTargets().size(), + mRequest.getCallerChooserTargets().size(), targetInfo.getHashedTargetIdForMetrics(this), targetInfo.isPinned(), mIsSuccessfullySelected, @@ -954,7 +1816,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mIsSuccessfullySelected, selectionCost ); - return; } } } @@ -976,19 +1837,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return -1; } - @Override - protected boolean shouldAddFooterView() { - // To accommodate for window insets - return true; - } - - @Override protected void applyFooterView(int height) { - int count = mChooserMultiProfilePagerAdapter.getItemCount(); - - for (int i = 0; i < count; i++) { - mChooserMultiProfilePagerAdapter.getAdapterForIndex(i).setFooterHeight(height); - } + mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height); } private void logDirectShareTargetReceived(UserHandle forUser) { @@ -1008,7 +1858,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (info != null) { sendClickToAppPredictor(info); final ResolveInfo ri = info.getResolveInfo(); - Intent targetIntent = getTargetIntent(); + Intent targetIntent = mRequest.getTargetIntent(); if (ri != null && ri.activityInfo != null && targetIntent != null) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); @@ -1036,7 +1886,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (targetIntent == null) { return; } - Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent()); + Intent originalTargetIntent = new Intent(mRequest.getTargetIntent()); // Our TargetInfo implementations add associated component to the intent, let's do the same // for the sake of the comparison below. if (targetIntent.getComponent() != null) { @@ -1106,101 +1956,36 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ProfileRecord record = getProfileRecord(userHandle); // We cannot use APS service when clone profile is present as APS service cannot sort // cross profile targets as of now. - return ((record == null) || (getAnnotatedUserHandles().cloneProfileUserHandle != null)) + return ((record == null) || (mProfiles.getCloneUserPresent())) ? null : record.appPredictor; } - /** - * Sort intents alphabetically based on display label. - */ - static class AzInfoComparator implements Comparator<DisplayResolveInfo> { - Comparator<DisplayResolveInfo> mComparator; - AzInfoComparator(Context context) { - Collator collator = Collator - .getInstance(context.getResources().getConfiguration().locale); - // Adding two stage comparator, first stage compares using displayLabel, next stage - // compares using resolveInfo.userHandle - mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) - .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier()); - } - - @Override - public int compare( - DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { - return mComparator.compare(lhsp, rhsp); - } - } - protected EventLog getEventLog() { return mEventLog; } - public class ChooserListController extends ResolverListController { - public ChooserListController( - Context context, - PackageManager pm, - Intent targetIntent, - String referrerPackageName, - int launchedFromUid, - AbstractResolverComparator resolverComparator, - UserHandle queryIntentsAsUser) { - super( - context, - pm, - targetIntent, - referrerPackageName, - launchedFromUid, - resolverComparator, - queryIntentsAsUser); - } - - @Override - public boolean isComponentFiltered(ComponentName name) { - return mChooserRequest.getFilteredComponentNames().contains(name); - } - - @Override - public boolean isComponentPinned(ComponentName name) { - return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); - } - } - - @VisibleForTesting - public ChooserGridAdapter createChooserGridAdapter( + private ChooserGridAdapter createChooserGridAdapter( Context context, List<Intent> payloadIntents, Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, initialIntents, - rList, - filterLastUsed, + /* TODO: not used, remove. rList= */ null, + /* TODO: not used, remove. filterLastUsed= */ false, createListController(userHandle), userHandle, - getTargetIntent(), - mChooserRequest.getReferrerFillInIntent(), - mMaxTargetsPerRow, - targetDataLoader); + mRequest.getTargetIntent(), + mRequest.getReferrerFillInIntent(), + 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); - } - - @Override public void onTargetSelected(int itemIndex) { startSelected(itemIndex, false, true); } @@ -1236,11 +2021,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements UserHandle userHandle, Intent targetIntent, Intent referrerFillInIntent, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) - ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + int maxTargetsPerRow) { + UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); return new ChooserListAdapter( context, payloadIntents, @@ -1252,54 +2034,70 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetIntent, referrerFillInIntent, this, - context.getPackageManager(), + mPackageManager, getEventLog(), maxTargetsPerRow, initialIntentsUserSpace, - targetDataLoader, - null, + mTargetDataLoader, + () -> { + ProfileRecord record = getProfileRecord(userHandle); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + }, mFeatureFlags); } - @Override - protected void onWorkProfileStatusUpdated() { - UserHandle workUser = getAnnotatedUserHandles().workProfileUserHandle; + private void onWorkProfileStatusUpdated() { + UserHandle workUser = mProfiles.getWorkHandle(); ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - super.onWorkProfileStatusUpdated(); + if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals( + mProfiles.getWorkHandle())) { + mChooserMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); + } } - @Override @VisibleForTesting protected ChooserListController createListController(UserHandle userHandle) { AppPredictor appPredictor = getAppPredictor(userHandle); AbstractResolverComparator resolverComparator; if (appPredictor != null) { - resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getEventLog(), - getIntegratedDeviceComponents().getNearbySharingComponent()); + resolverComparator = new AppPredictionServiceResolverComparator( + this, + mRequest.getTargetIntent(), + mRequest.getLaunchedFromPackage(), + appPredictor, + userHandle, + getEventLog(), + mNearbyShare.orElse(null) + ); } else { resolverComparator = new ResolverRankerServiceResolverComparator( this, - getTargetIntent(), - getReferrerPackageName(), + mRequest.getTargetIntent(), + mRequest.getReferrerPackage(), null, getEventLog(), getResolverRankerServiceUserHandleList(userHandle), - getIntegratedDeviceComponents().getNearbySharingComponent()); + mNearbyShare.orElse(null)); } return new ChooserListController( this, - mPm, - getTargetIntent(), - getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp, + mPackageManager, + mRequest.getTargetIntent(), + mRequest.getReferrerPackage(), + mViewModel.getActivityModel().getLaunchedFromUid(), resolverComparator, - getQueryIntentsUser(userHandle)); + mProfiles.getQueryIntentsHandle(userHandle), + mRequest.getFilteredComponentNames(), + mPinnedSharedPrefs); } @VisibleForTesting @@ -1307,11 +2105,70 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return PreviewViewModel.Companion.getFactory(); } - private ChooserActionFactory createChooserActionFactory() { + private ChooserContentPreviewUi.ActionFactory decorateActionFactoryWithRefinement( + ChooserContentPreviewUi.ActionFactory originalFactory) { + if (!mFeatureFlags.refineSystemActions()) { + return originalFactory; + } + + return new ChooserContentPreviewUi.ActionFactory() { + @Override + @Nullable + public Runnable getEditButtonRunnable() { + return () -> { + if (!mRefinementManager.maybeHandleSelection( + RefinementType.EDIT_ACTION, + List.of(mRequest.getTargetIntent()), + null, + mRequest.getRefinementIntentSender(), + getApplication(), + getMainThreadHandler())) { + originalFactory.getEditButtonRunnable().run(); + } + }; + } + + @Override + @Nullable + public Runnable getCopyButtonRunnable() { + return () -> { + if (!mRefinementManager.maybeHandleSelection( + RefinementType.COPY_ACTION, + List.of(mRequest.getTargetIntent()), + null, + mRequest.getRefinementIntentSender(), + getApplication(), + getMainThreadHandler())) { + originalFactory.getCopyButtonRunnable().run(); + } + }; + } + + @Override + public List<ActionRow.Action> createCustomActions() { + return originalFactory.createCustomActions(); + } + + @Override + @Nullable + public ActionRow.Action getModifyShareAction() { + return originalFactory.getModifyShareAction(); + } + + @Override + public Consumer<Boolean> getExcludeSharedTextAction() { + return originalFactory.getExcludeSharedTextAction(); + } + }; + } + + private ChooserActionFactory createChooserActionFactory(Intent targetIntent) { return new ChooserActionFactory( this, - mChooserRequest, - mIntegratedDeviceComponents, + targetIntent, + mRequest.getLaunchedFromPackage(), + mRequest.getChooserActions(), + mImageEditor, getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, this::getFirstVisibleImgPreviewView, @@ -1319,7 +2176,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { safelyStartActivityAsUser( - targetInfo, getAnnotatedUserHandles().personalProfileUserHandle); + targetInfo, + mProfiles.getPersonalHandle() + ); finish(); } @@ -1330,19 +2189,32 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserActivity.this, sharedElement, sharedElementName); safelyStartActivityAsUser( targetInfo, - getAnnotatedUserHandles().personalProfileUserHandle, + mProfiles.getPersonalHandle(), options.toBundle()); // Can't finish right away because the shared element transition may not // be ready to start. mFinishWhenStopped = true; } }, - (status) -> { - if (status != null) { - setResult(status); - } - finish(); - }); + mShareResultSender, + this::finishWithStatus, + mClipboardManager); + } + + private Supplier<ActionRow.Action> createModifyShareActionFactory() { + return () -> ChooserActionFactory.createCustomAction( + ChooserActivity.this, + mRequest.getModifyShareAction(), + () -> getEventLog().logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE), + mShareResultSender, + this::finishWithStatus); + } + + private void finishWithStatus(@Nullable Integer status) { + if (status != null) { + setResult(status); + } + finish(); } /* @@ -1352,7 +2224,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements */ private void handleLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { - if (mChooserMultiProfilePagerAdapter == null) { + if (mChooserMultiProfilePagerAdapter == null || !isProfilePagerAdapterAttached()) { return; } RecyclerView recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -1387,8 +2259,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements updateTabPadding(); } - UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); - int currentProfile = getProfileForUser(currentUserHandle); + int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile(); int initialProfile = findSelectedProfile(); if (currentProfile != initialProfile) { return; @@ -1414,8 +2285,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int top, int bottom, RecyclerView recyclerView, ChooserGridAdapter gridAdapter) { int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; - int rowsToShow = gridAdapter.getSystemRowCount() - + gridAdapter.getServiceTargetRowCount() + int rowsToShow = gridAdapter.getServiceTargetRowCount() + gridAdapter.getCallerAndRankedTargetRowCount(); // then this is most likely not a SEND_* action, so check @@ -1437,7 +2307,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements offset += stickyContentPreview.getHeight(); } - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { offset += findViewById(com.android.internal.R.id.tabs).getHeight(); } @@ -1460,7 +2330,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements rowsToShow--; } } else { - ViewGroup currentEmptyStateView = getActiveEmptyStateView(); + ViewGroup currentEmptyStateView = + mChooserMultiProfilePagerAdapter.getActiveEmptyStateView(); if (currentEmptyStateView.getVisibility() == View.VISIBLE) { offset += currentEmptyStateView.getHeight(); } @@ -1469,43 +2340,21 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return Math.min(offset, bottom - top); } + private boolean isProfilePagerAdapterAttached() { + return mChooserMultiProfilePagerAdapter == mViewPager.getAdapter(); + } + /** * If we have a tabbed view and are showing 1 row in the current profile and an empty - * state screen in the other profile, to prevent cropping of the empty state screen we show + * state screen in another profile, to prevent cropping of the empty state screen we show * a second row in the current profile. */ private boolean shouldShowExtraRow(int rowsToShow) { - return shouldShowTabs() - && rowsToShow == 1 - && mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen( - mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); - } - - /** - * Returns {@link #PROFILE_WORK}, if the given user handle matches work user handle. - * Returns {@link #PROFILE_PERSONAL}, otherwise. - **/ - private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(getAnnotatedUserHandles().workProfileUserHandle)) { - return PROFILE_WORK; - } - // We return personal profile, as it is the default when there is no work profile, personal - // profile represents rootUser, clonedUser & secondaryUser, covering all use cases. - return PROFILE_PERSONAL; - } - - private ViewGroup getActiveEmptyStateView() { - int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); - return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage); - } - - @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); - super.onHandlePackagesChanged(listAdapter); + return rowsToShow == 1 + && mChooserMultiProfilePagerAdapter + .shouldShowEmptyStateScreenInAnyInactiveAdapter(); } - @Override protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); maybeSetupGlobalLayoutListener(); @@ -1522,9 +2371,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements //TODO: move this block inside ChooserListAdapter (should be called when // ResolverListAdapter#mPostListReadyRunnable is executed. if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { + if (rebuildComplete && mChooserServiceFeatureFlags.chooserPayloadToggling()) { + onAppTargetsLoaded(listAdapter); + } chooserListAdapter.notifyDataSetChanged(); } else { - chooserListAdapter.updateAlphabeticalList(); + if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { + chooserListAdapter.updateAlphabeticalList( + () -> onAppTargetsLoaded(listAdapter)); + } else { + chooserListAdapter.updateAlphabeticalList(); + } } if (rebuildComplete) { @@ -1575,7 +2432,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements adapter.completeServiceTargetLoading(); } - if (mMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { long duration = Tracer.INSTANCE.endLaunchToShortcutTrace(); if (duration >= 0) { Log.d(TAG, "stat to first shortcut time: " + duration + " ms"); @@ -1590,7 +2447,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (mResolverDrawerLayout == null) { return; } - int elevatedViewResId = shouldShowTabs() ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; + int elevatedViewResId = mProfiles.getWorkProfilePresent() + ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId); final float defaultElevation = elevatedView.getElevation(); final float chooserHeaderScrollElevation = @@ -1598,7 +2456,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( new RecyclerView.OnScrollListener() { @Override - public void onScrollStateChanged(@NonNull RecyclerView view, int scrollState) { + public void onScrollStateChanged(RecyclerView view, int scrollState) { if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { mScrollStatus = SCROLL_STATUS_IDLE; @@ -1613,7 +2471,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } @Override - public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { + public void onScrolled(RecyclerView view, int dx, int dy) { if (view.getChildCount() > 0) { View child = view.getLayoutManager().findViewByPosition(0); if (child == null || child.getTop() < 0) { @@ -1628,7 +2486,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void maybeSetupGlobalLayoutListener() { - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { return; } final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -1662,11 +2520,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (!shouldShowContentPreview()) { return false; } - ResolverListAdapter adapter = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())); boolean isEmpty = adapter == null || adapter.getCount() == 0; - return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) - && (!isEmpty || shouldShowContentPreviewWhenEmpty()); + return !isEmpty || shouldShowContentPreviewWhenEmpty(); } /** @@ -1684,7 +2541,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { - return (mChooserRequest != null) && mChooserRequest.isSendActionTarget(); + return mRequest.isSendActionTarget(); } private void updateStickyContentPreview() { @@ -1728,34 +2585,22 @@ public class ChooserActivity extends Hilt_ChooserActivity implements contentPreviewContainer.setVisibility(View.GONE); } - private View findRootView() { - if (mContentView == null) { - mContentView = findViewById(android.R.id.content); - } - return mContentView; - } - - /** - * Intentionally override the {@link ResolverActivity} implementation as we only need that - * implementation for the intent resolver case. - */ - @Override - public void onButtonClick(View v) {} - - /** - * Intentionally override the {@link ResolverActivity} implementation as we only need that - * implementation for the intent resolver case. - */ - @Override - protected void resetButtonBar() {} - - @Override protected String getMetricsCategory() { return METRICS_CATEGORY_CHOOSER; } - @Override - protected void onProfileTabSelected() { + protected void onProfileTabSelected(int currentPage) { + setupViewVisibilities(); + maybeLogProfileChange(); + if (mProfiles.getWorkProfilePresent()) { + // The device policy logger is only concerned with sessions that include a work profile. + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(currentPage) + .setStrings(getMetricsCategory()) + .write(); + } + // This fixes an edge case where after performing a variety of gestures, vertical scrolling // ends up disabled. That's because at some point the old tab's vertical scrolling is // disabled and the new tab's is enabled. For context, see b/159997845 @@ -1765,25 +2610,28 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - if (shouldShowTabs()) { + mSystemWindowInsets = insets.getInsets(WindowInsets.Type.systemBars()); + if (mFeatureFlags.fixEmptyStatePaddingBug() || mProfiles.getWorkProfilePresent()) { mChooserMultiProfilePagerAdapter - .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); - mChooserMultiProfilePagerAdapter.setupContainerPadding( - getActiveEmptyStateView().findViewById(com.android.internal.R.id.resolver_empty_state_container)); + .setEmptyStateBottomOffset(mSystemWindowInsets.bottom); } - WindowInsets result = super.onApplyWindowInsets(v, insets); + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + + // Need extra padding so the list can fully scroll up + // To accommodate for window insets + applyFooterView(mSystemWindowInsets.bottom); + if (mResolverDrawerLayout != null) { mResolverDrawerLayout.requestLayout(); } - return result; + return WindowInsets.CONSUMED; } private void setHorizontalScrollingEnabled(boolean enabled) { - ResolverViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSwipingEnabled(enabled); + mViewPager.setSwipingEnabled(enabled); } private void setVerticalScrollEnabled(boolean enabled) { @@ -1793,7 +2641,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements layoutManager.setVerticalScrollEnabled(enabled); } - @Override void onHorizontalSwipeStateChanged(int state) { if (state == ViewPager.SCROLL_STATE_DRAGGING) { if (mScrollStatus == SCROLL_STATUS_IDLE) { @@ -1808,7 +2655,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override protected void maybeLogProfileChange() { getEventLog().logSharesheetProfileChanged(); } diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt new file mode 100644 index 00000000..6317ee1d --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserHelper.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.app.Activity +import android.os.UserHandle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.ui.viewmodel.ChooserViewModel +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.log +import dagger.hilt.android.scopes.ActivityScoped +import java.util.function.Consumer +import javax.inject.Inject +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +private const val TAG: String = "ChooserHelper" + +/** + * __Purpose__ + * + * Cleanup aid. Provides a pathway to cleaner code. + * + * __Incoming References__ + * + * ChooserHelper must not expose any properties or functions directly back to ChooserActivity. If a + * value or operation is required by ChooserActivity, then it must be added to ChooserInitializer + * (or a new interface as appropriate) with ChooserActivity supplying a callback to receive it at + * the appropriate point. This enforces unidirectional control flow. + * + * __Outgoing References__ + * + * _ChooserActivity_ + * + * This class must only reference it's host as Activity/ComponentActivity; no down-cast to + * [ChooserActivity]. Other components should be created here or supplied via Injection, and not + * referenced directly within ChooserActivity. This prevents circular dependencies from forming. If + * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described + * above in 'Incoming References', see [ChooserInitializer]. + * + * _Elsewhere_ + * + * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of + * referenced from an existing location. If not available for injection, the value should be + * constructed here, then provided to where it is needed. + */ +@ActivityScoped +@JavaInterop +class ChooserHelper +@Inject +constructor( + hostActivity: Activity, + private val activityResultRepo: ActivityResultRepository, + private val pendingSelectionCallbackRepo: PendingSelectionCallbackRepository, +) : DefaultLifecycleObserver { + // This is guaranteed by Hilt, since only a ComponentActivity is injectable. + private val activity: ComponentActivity = hostActivity as ComponentActivity + private val viewModel by activity.viewModels<ChooserViewModel>() + + // TODO: provide the following through an init object passed into [setInitialize] + private lateinit var activityInitializer: Runnable + /** Invoked when there are updates to ChooserRequest */ + var onChooserRequestChanged: Consumer<ChooserRequest> = Consumer {} + /** Invoked when there are a new change to payload selection */ + var onPendingSelection: Runnable = Runnable {} + + init { + activity.lifecycle.addObserver(this) + } + + /** + * Set the initialization hook for the host activity. + * + * This _must_ be called from [ChooserActivity.onCreate]. + */ + fun setInitializer(initializer: Runnable) { + check(activity.lifecycle.currentState == Lifecycle.State.INITIALIZED) { + "setInitializer must be called before onCreate returns" + } + activityInitializer = initializer + } + + /** Invoked by Lifecycle, after [ChooserActivity.onCreate] _returns_. */ + override fun onCreate(owner: LifecycleOwner) { + Log.i(TAG, "CREATE") + Log.i(TAG, "${viewModel.activityModel}") + + val callerUid: Int = viewModel.activityModel.launchedFromUid + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a chooser from uid $callerUid") + activity.finish() + return + } + + when (val request = viewModel.initialRequest) { + is Valid -> initializeActivity(request) + is Invalid -> reportErrorsAndFinish(request) + } + + activity.lifecycleScope.launch { + activity.setResult(activityResultRepo.activityResult.filterNotNull().first()) + activity.finish() + } + + activity.lifecycleScope.launch { + val hasPendingCallbackFlow = + pendingSelectionCallbackRepo.pendingTargetIntent + .map { it != null } + .distinctUntilChanged() + .onEach { hasPendingCallback -> + if (hasPendingCallback) { + onPendingSelection.run() + } + } + activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.request + .combine(hasPendingCallbackFlow) { request, hasPendingCallback -> + request to hasPendingCallback + } + // only take ChooserRequest if there are no pending callbacks + .filter { !it.second } + .map { it.first } + .distinctUntilChanged(areEquivalent = { old, new -> old === new }) + .collect { onChooserRequestChanged.accept(it) } + } + } + } + + override fun onStart(owner: LifecycleOwner) { + Log.i(TAG, "START") + } + + override fun onResume(owner: LifecycleOwner) { + Log.i(TAG, "RESUME") + } + + override fun onPause(owner: LifecycleOwner) { + Log.i(TAG, "PAUSE") + } + + override fun onStop(owner: LifecycleOwner) { + Log.i(TAG, "STOP") + } + + override fun onDestroy(owner: LifecycleOwner) { + Log.i(TAG, "DESTROY") + } + + private fun reportErrorsAndFinish(request: Invalid<ChooserRequest>) { + request.errors.forEach { it.log(TAG) } + activity.finish() + } + + private fun initializeActivity(request: Valid<ChooserRequest>) { + request.warnings.forEach { it.log(TAG) } + activityInitializer.run() + } +} diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java deleted file mode 100644 index 7cd86bf4..00000000 --- a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.content.ComponentName; -import android.content.Context; -import android.provider.Settings; -import android.text.TextUtils; - -import androidx.annotation.Nullable; - -import com.android.internal.annotations.VisibleForTesting; - -/** - * Helper to look up the components available on this device to handle assorted built-in actions - * like "Edit" that may be displayed for certain content/preview types. The components are queried - * when this record is instantiated, and are then immutable for a given instance. - * - * Because this describes the app's external execution environment, test methods may prefer to - * provide explicit values to override the default lookup logic. - */ -public class ChooserIntegratedDeviceComponents { - @Nullable - private final ComponentName mEditSharingComponent; - - @Nullable - private final ComponentName mNearbySharingComponent; - - /** Look up the integrated components available on this device. */ - public static ChooserIntegratedDeviceComponents get( - Context context, - SecureSettings secureSettings) { - return new ChooserIntegratedDeviceComponents( - getEditSharingComponent(context), - getNearbySharingComponent(context, secureSettings)); - } - - @VisibleForTesting - ChooserIntegratedDeviceComponents( - @Nullable ComponentName editSharingComponent, - @Nullable ComponentName nearbySharingComponent) { - mEditSharingComponent = editSharingComponent; - mNearbySharingComponent = nearbySharingComponent; - } - - public ComponentName getEditSharingComponent() { - return mEditSharingComponent; - } - - public ComponentName getNearbySharingComponent() { - return mNearbySharingComponent; - } - - private static ComponentName getEditSharingComponent(Context context) { - String editorComponent = context.getApplicationContext().getString( - R.string.config_systemImageEditor); - return TextUtils.isEmpty(editorComponent) - ? null : ComponentName.unflattenFromString(editorComponent); - } - - private static ComponentName getNearbySharingComponent(Context context, - SecureSettings secureSettings) { - String nearbyComponent = secureSettings.getString( - context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT); - if (TextUtils.isEmpty(nearbyComponent)) { - nearbyComponent = context.getString(R.string.config_defaultNearbySharingComponent); - } - return TextUtils.isEmpty(nearbyComponent) - ? null : ComponentName.unflattenFromString(nearbyComponent); - } -} diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 5060f4f1..8b848e55 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -48,6 +48,7 @@ import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.DisplayResolveInfoAzInfoComparator; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; @@ -152,6 +153,8 @@ public class ChooserListAdapter extends ResolverListAdapter { } }; + private boolean mAnimateItems = true; + public ChooserListAdapter( Context context, List<Intent> payloadIntents, @@ -307,6 +310,10 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + public void setAnimateItems(boolean animateItems) { + mAnimateItems = animateItems; + } + @Override public void handlePackagesChanged() { if (mPackageChangeCallback != null) { @@ -346,9 +353,16 @@ public class ChooserListAdapter extends ResolverListAdapter { false); } + @Override + public void onDestroy() { + super.onDestroy(); + notifyDataSetChanged(); + } + @VisibleForTesting @Override public void onBindView(View view, TargetInfo info, int position) { + view.setEnabled(!isDestroyed()); final ViewHolder holder = (ViewHolder) view.getTag(); resetViewHolder(holder); @@ -363,18 +377,15 @@ public class ChooserListAdapter extends ResolverListAdapter { final CharSequence displayLabel = Objects.requireNonNullElse(info.getDisplayLabel(), ""); final CharSequence extendedInfo = Objects.requireNonNullElse(info.getExtendedInfo(), ""); holder.bindLabel(displayLabel, extendedInfo); - if (!TextUtils.isEmpty(displayLabel)) { + if (mAnimateItems && !TextUtils.isEmpty(displayLabel)) { mAnimationTracker.animateLabel(holder.text, info); } - if (!TextUtils.isEmpty(extendedInfo) && holder.text2.getVisibility() == View.VISIBLE) { + if (mAnimateItems + && !TextUtils.isEmpty(extendedInfo) + && holder.text2.getVisibility() == View.VISIBLE) { mAnimationTracker.animateLabel(holder.text2, info); } - holder.bindIcon(info); - if (info.hasDisplayIcon()) { - mAnimationTracker.animateIcon(holder.icon, info); - } - if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); @@ -410,6 +421,11 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + holder.bindIcon(info); + if (mAnimateItems && info.hasDisplayIcon()) { + mAnimationTracker.animateIcon(holder.icon, info); + } + if (info.isPlaceHolderTargetInfo()) { bindPlaceholder(holder); } @@ -477,9 +493,19 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + /** + * Group application targets + */ public void updateAlphabeticalList() { - final ChooserActivity.AzInfoComparator comparator = - new ChooserActivity.AzInfoComparator(mContext); + updateAlphabeticalList(() -> {}); + } + + /** + * Group application targets + */ + public void updateAlphabeticalList(Runnable onCompleted) { + final DisplayResolveInfoAzInfoComparator + comparator = new DisplayResolveInfoAzInfoComparator(mContext); final List<DisplayResolveInfo> allTargets = new ArrayList<>(); allTargets.addAll(getTargetsInCurrentDisplayList()); allTargets.addAll(mCallerTargets); @@ -522,6 +548,7 @@ public class ChooserListAdapter extends ResolverListAdapter { mSortedList.clear(); mSortedList.addAll(newList); notifyDataSetChanged(); + onCompleted.run(); } private void loadMissingLabels(List<DisplayResolveInfo> targets) { @@ -711,7 +738,7 @@ public class ChooserListAdapter extends ResolverListAdapter { public void addServiceResults( @Nullable DisplayResolveInfo origTarget, List<ChooserTarget> targets, - @ChooserActivity.ShareTargetType int targetType, + int targetType, Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos, Map<ChooserTarget, AppTarget> directShareToAppTargets) { // Avoid inserting any potentially late results. @@ -748,7 +775,7 @@ public class ChooserListAdapter extends ResolverListAdapter { */ public float getBaseScore( DisplayResolveInfo target, - @ChooserActivity.ShareTargetType int targetType) { + int targetType) { if (target == null) { return CALLER_TARGET_SCORE_BOOST; } diff --git a/java/src/com/android/intentresolver/v2/ChooserListController.java b/java/src/com/android/intentresolver/ChooserListController.java index 467f343b..48aa8be1 100644 --- a/java/src/com/android/intentresolver/v2/ChooserListController.java +++ b/java/src/com/android/intentresolver/ChooserListController.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2; +package com.android.intentresolver; import android.content.ComponentName; import android.content.Context; @@ -23,7 +23,6 @@ import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.UserHandle; -import com.android.intentresolver.ResolverListController; import com.android.intentresolver.model.AbstractResolverComparator; import java.util.List; diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java deleted file mode 100644 index 080f9d24..00000000 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ /dev/null @@ -1,214 +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.content.Context; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager.widget.PagerAdapter; - -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.measurements.Tracer; -import com.android.internal.annotations.VisibleForTesting; - -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 MultiProfilePagerAdapter< - RecyclerView, ChooserGridAdapter, ChooserListAdapter> { - private static final int SINGLE_CELL_SPAN_SIZE = 1; - - private final ChooserProfileAdapterBinder mAdapterBinder; - private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - - public ChooserMultiProfilePagerAdapter( - Context context, - ChooserGridAdapter adapter, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - int maxTargetsPerRow, - FeatureFlags featureFlags) { - this( - context, - new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of(adapter), - emptyStateProvider, - workProfileQuietModeChecker, - /* defaultProfile= */ 0, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context), - featureFlags); - } - - public ChooserMultiProfilePagerAdapter( - Context context, - ChooserGridAdapter personalAdapter, - ChooserGridAdapter workAdapter, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - int maxTargetsPerRow, - FeatureFlags featureFlags) { - this( - context, - new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of(personalAdapter, workAdapter), - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context), - featureFlags); - } - - private ChooserMultiProfilePagerAdapter( - Context context, - ChooserProfileAdapterBinder adapterBinder, - ImmutableList<ChooserGridAdapter> gridAdapters, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, - FeatureFlags featureFlags) { - super( - gridAdapter -> gridAdapter.getListAdapter(), - adapterBinder, - gridAdapters, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - () -> makeProfileView(context, featureFlags), - bottomPaddingOverrideSupplier); - mAdapterBinder = adapterBinder; - mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; - } - - public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mAdapterBinder.setMaxTargetsPerRow(maxTargetsPerRow); - } - - public void setEmptyStateBottomOffset(int bottomOffset) { - mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); - } - - /** - * Notify adapter about the drawer's collapse state. This will affect the app divider's - * visibility. - */ - public void setIsCollapsed(boolean isCollapsed) { - for (int i = 0, size = getItemCount(); i < size; i++) { - getAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); - } - } - - private static ViewGroup makeProfileView( - Context context, FeatureFlags featureFlags) { - LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = featureFlags.scrollablePreview() - ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) - : (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 - public boolean rebuildActiveTab(boolean doPostProcessing) { - if (doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(getActiveListAdapter().getUserHandle()); - } - return super.rebuildActiveTab(doPostProcessing); - } - - @Override - public boolean rebuildInactiveTab(boolean doPostProcessing) { - if (getItemCount() != 1 && doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(getInactiveListAdapter().getUserHandle()); - } - return super.rebuildInactiveTab(doPostProcessing); - } - - private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { - private final Context mContext; - private int mBottomOffset; - - BottomPaddingOverrideSupplier(Context context) { - mContext = context; - } - - public void setEmptyStateBottomOffset(int bottomOffset) { - mBottomOffset = bottomOffset; - } - - public Optional<Integer> get() { - int initialBottomPadding = mContext.getResources().getDimensionPixelSize( - R.dimen.resolver_empty_state_container_padding_bottom); - return Optional.of(initialBottomPadding + mBottomOffset); - } - } - - private static class ChooserProfileAdapterBinder implements - AdapterBinder<RecyclerView, ChooserGridAdapter> { - private int mMaxTargetsPerRow; - - ChooserProfileAdapterBinder(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; - } - - public void setMaxTargetsPerRow(int maxTargetsPerRow) { - mMaxTargetsPerRow = maxTargetsPerRow; - } - - @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/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index 474b240f..5c828a8e 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -41,7 +41,6 @@ import java.util.function.Consumer; import javax.inject.Inject; - /** * Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement * activity" that will be invoked when a target is selected, allowing the calling app to add @@ -60,22 +59,58 @@ public final class ChooserRefinementManager extends ViewModel { private boolean mConfigurationChangeInProgress = false; /** + * The types of selections that may be sent to refinement. + * + * The refinement flow results in a refined intent, but the interpretation of that intent + * depends on the type of selection that prompted the refinement. + */ + public enum RefinementType { + TARGET_INFO, // A normal (`TargetInfo`) target. + + // System actions derived from the refined intent (from `ChooserActionFactory`). + COPY_ACTION, + EDIT_ACTION + } + + /** * A token for the completion of a refinement process that can be consumed exactly once. */ public static class RefinementCompletion { private TargetInfo mTargetInfo; private boolean mConsumed; + private final RefinementType mType; + + @Nullable + private final TargetInfo mOriginalTargetInfo; + + @Nullable + private final Intent mRefinedIntent; + + RefinementCompletion( + @Nullable RefinementType type, + @Nullable TargetInfo originalTargetInfo, + @Nullable Intent refinedIntent) { + mType = type; + mOriginalTargetInfo = originalTargetInfo; + mRefinedIntent = refinedIntent; + } - RefinementCompletion(TargetInfo targetInfo) { - mTargetInfo = targetInfo; + public RefinementType getType() { + return mType; + } + + @Nullable + public TargetInfo getOriginalTargetInfo() { + return mOriginalTargetInfo; } /** * @return The output of the completed refinement process. Null if the process was aborted * or failed. */ - public TargetInfo getTargetInfo() { - return mTargetInfo; + @Nullable + public Intent getRefinedIntent() { + return mRefinedIntent; } /** @@ -106,14 +141,11 @@ public final class ChooserRefinementManager extends ViewModel { * @return true if the selection should wait for a now-started refinement flow, or false if it * can proceed by the default (non-refinement) logic. */ - public boolean maybeHandleSelection(TargetInfo selectedTarget, - IntentSender refinementIntentSender, Application application, Handler mainHandler) { - if (refinementIntentSender == null) { - return false; - } - if (selectedTarget.getAllSourceIntents().isEmpty()) { - return false; - } + public boolean maybeHandleSelection( + TargetInfo selectedTarget, + IntentSender refinementIntentSender, + Application application, + Handler mainHandler) { if (selectedTarget.isSuspended()) { // We expect all launches to fail for this target, so don't make the user go through the // refinement flow first. Besides, the default (non-refinement) handling displays a @@ -122,27 +154,57 @@ public final class ChooserRefinementManager extends ViewModel { return false; } + return maybeHandleSelection( + RefinementType.TARGET_INFO, + selectedTarget.getAllSourceIntents(), + selectedTarget, + refinementIntentSender, + application, + mainHandler); + } + + /** + * Delegate the user's selection of targets (with one or more matching {@code sourceIntents} to + * the refinement flow, if possible. + * @return true if the selection should wait for a now-started refinement flow, or false if it + * can proceed by the default (non-refinement) logic. + */ + public boolean maybeHandleSelection( + RefinementType refinementType, + List<Intent> sourceIntents, + @Nullable TargetInfo originalTargetInfo, + IntentSender refinementIntentSender, + Application application, + Handler mainHandler) { + // Our requests have a non-null `originalTargetInfo` in exactly the + // cases when `refinementType == TARGET_INFO`. + assert ((originalTargetInfo == null) == (refinementType == RefinementType.TARGET_INFO)); + + if (refinementIntentSender == null) { + return false; + } + if (sourceIntents.isEmpty()) { + return false; + } + destroy(); // Terminate any prior sessions. mRefinementResultReceiver = new RefinementResultReceiver( + refinementType, refinedIntent -> { destroy(); - - TargetInfo refinedTarget = - selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent); - if (refinedTarget != null) { - mRefinementCompletion.setValue(new RefinementCompletion(refinedTarget)); - } else { - Log.e(TAG, "Failed to apply refinement to any matching source intent"); - mRefinementCompletion.setValue(new RefinementCompletion(null)); - } + mRefinementCompletion.setValue( + new RefinementCompletion( + refinementType, originalTargetInfo, refinedIntent)); }, () -> { destroy(); - mRefinementCompletion.setValue(new RefinementCompletion(null)); + mRefinementCompletion.setValue( + new RefinementCompletion( + refinementType, originalTargetInfo, null)); }, mainHandler); - Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget); + Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, sourceIntents); try { refinementIntentSender.sendIntent(application, 0, refinementRequest, null, null); return true; @@ -168,7 +230,7 @@ public final class ChooserRefinementManager extends ViewModel { // into a valid Chooser session, so we'll treat it as a cancellation instead. Log.w(TAG, "Chooser resumed while awaiting refinement result; aborting"); destroy(); - mRefinementCompletion.setValue(new RefinementCompletion(null)); + mRefinementCompletion.setValue(new RefinementCompletion(null, null, null)); } } } @@ -188,9 +250,8 @@ public final class ChooserRefinementManager extends ViewModel { } private static Intent makeRefinementRequest( - RefinementResultReceiver resultReceiver, TargetInfo originalTarget) { + RefinementResultReceiver resultReceiver, List<Intent> sourceIntents) { final Intent fillIn = new Intent(); - final List<Intent> sourceIntents = originalTarget.getAllSourceIntents(); fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0)); final int sourceIntentCount = sourceIntents.size(); if (sourceIntentCount > 1) { @@ -205,16 +266,19 @@ public final class ChooserRefinementManager extends ViewModel { } private static class RefinementResultReceiver extends ResultReceiver { + private final RefinementType mType; private final Consumer<Intent> mOnSelectionRefined; private final Runnable mOnRefinementCancelled; private boolean mDestroyed; RefinementResultReceiver( + RefinementType type, Consumer<Intent> onSelectionRefined, Runnable onRefinementCancelled, Handler handler) { super(handler); + mType = type; mOnSelectionRefined = onSelectionRefined; mOnRefinementCancelled = onRefinementCancelled; } diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 6c7f8264..06f56e3b 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import static java.util.Objects.requireNonNullElse; import android.content.ComponentName; import android.content.Intent; @@ -43,7 +42,6 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.stream.Collector; import java.util.stream.Collectors; diff --git a/java/src/com/android/intentresolver/v2/ChooserSelector.kt b/java/src/com/android/intentresolver/ChooserSelector.kt index 378bc06c..c1174e95 100644 --- a/java/src/com/android/intentresolver/v2/ChooserSelector.kt +++ b/java/src/com/android/intentresolver/ChooserSelector.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.v2 import android.content.BroadcastReceiver diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt index b1178aa5..6a4fe65a 100644 --- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -21,14 +21,14 @@ import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback import com.android.internal.annotations.VisibleForTesting +import java.util.function.Supplier import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.util.function.Supplier /** - * A helper class to track app's readiness for the scene transition animation. - * The app is ready when both the image is laid out and the drawer offset is calculated. + * 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. */ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) class EnterTransitionAnimationDelegate( @@ -45,21 +45,22 @@ class EnterTransitionAnimationDelegate( activity.setEnterSharedElementCallback( object : SharedElementCallback() { override fun onMapSharedElements( - names: MutableList<String>, sharedElements: MutableMap<String, View> + names: MutableList<String>, + sharedElements: MutableMap<String, View> ) { - this@EnterTransitionAnimationDelegate.onMapSharedElements( - names, sharedElements - ) + this@EnterTransitionAnimationDelegate.onMapSharedElements(names, sharedElements) } - }) + } + ) } fun postponeTransition() { activity.postponeEnterTransition() - timeoutJob = activity.lifecycleScope.launch { - delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong()) - onTimeout() - } + timeoutJob = + activity.lifecycleScope.launch { + delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong()) + onTimeout() + } } private fun onTimeout() { @@ -110,8 +111,14 @@ class EnterTransitionAnimationDelegate( override fun onLayoutChange( v: View, - left: Int, top: Int, right: Int, bottom: Int, - oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int ) { v.removeOnLayoutChangeListener(this) startPostponedEnterTransition() diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index 15996d00..db94c918 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -20,8 +20,8 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTEN import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK; import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY; -import static com.android.intentresolver.ResolverActivity.EXTRA_CALLING_USER; -import static com.android.intentresolver.ResolverActivity.EXTRA_SELECTED_PROFILE; +import static com.android.intentresolver.ui.viewmodel.ResolverRequestReaderKt.EXTRA_CALLING_USER; +import static com.android.intentresolver.ui.viewmodel.ResolverRequestReaderKt.EXTRA_SELECTED_PROFILE; import android.app.Activity; import android.app.ActivityThread; @@ -46,6 +46,7 @@ import android.widget.Toast; import androidx.annotation.Nullable; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -254,9 +255,9 @@ public class IntentForwarderActivity extends Activity { private int findSelectedProfile(String className) { if (className.equals(FORWARD_INTENT_TO_PARENT)) { - return ChooserActivity.PROFILE_PERSONAL; + return MultiProfilePagerAdapter.PROFILE_PERSONAL; } else if (className.equals(FORWARD_INTENT_TO_MANAGED_PROFILE)) { - return ChooserActivity.PROFILE_WORK; + return MultiProfilePagerAdapter.PROFILE_WORK; } return -1; } diff --git a/java/src/com/android/intentresolver/v2/IntentForwarding.kt b/java/src/com/android/intentresolver/IntentForwarding.kt index 3d366d10..c8f6cf41 100644 --- a/java/src/com/android/intentresolver/v2/IntentForwarding.kt +++ b/java/src/com/android/intentresolver/IntentForwarding.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2 +package com.android.intentresolver import android.Manifest import android.Manifest.permission.INTERACT_ACROSS_USERS @@ -28,7 +28,7 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.UserHandle import android.os.UserManager import android.util.Log -import com.android.intentresolver.v2.data.repository.DevicePolicyResources +import com.android.intentresolver.data.repository.DevicePolicyResources import javax.inject.Inject import javax.inject.Singleton diff --git a/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt b/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt index d3e07c6b..7deb0d10 100644 --- a/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt +++ b/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt @@ -37,9 +37,7 @@ internal class ItemRevealAnimationTracker { fun animateLabel(view: View, info: TargetInfo) = animateView(view, info, labelProgress) private fun animateView(view: View, info: TargetInfo, map: MutableMap<TargetInfo, Record>) { - val record = map.getOrPut(info) { - Record() - } + val record = map.getOrPut(info) { Record() } if ((view.animation as? RevealAnimation)?.record === record) return view.clearAnimation() diff --git a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt b/java/src/com/android/intentresolver/JavaFlowHelper.kt index c6c977f6..231cb809 100644 --- a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt +++ b/java/src/com/android/intentresolver/JavaFlowHelper.kt @@ -16,13 +16,15 @@ @file:JvmName("JavaFlowHelper") -package com.android.intentresolver.v2 +package com.android.intentresolver +import com.android.intentresolver.annotation.JavaInterop import java.util.function.Consumer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +@JavaInterop fun <T> collect(scope: CoroutineScope, flow: Flow<T>, collector: Consumer<T>): Job = scope.launch { flow.collect { collector.accept(it) } } diff --git a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java deleted file mode 100644 index 42a29e55..00000000 --- a/java/src/com/android/intentresolver/MultiProfilePagerAdapter.java +++ /dev/null @@ -1,583 +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.os.Trace; -import android.os.UserHandle; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; - -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.emptystate.EmptyStateUiHelper; -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). - * - * TODO: attempt to further restrict visibility/improve encapsulation 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 is part of an in-progress refactor to merge with `GenericMultiProfilePagerAdapter`. - * As originally noted there, we've reduced explicit references to the `ResolverListAdapter` base - * type and may be able to drop the type constraint. - */ -public class MultiProfilePagerAdapter< - PageViewT extends ViewGroup, - SinglePageAdapterT, - ListAdapterT extends ResolverListAdapter> extends PagerAdapter { - - /** - * Delegate to set up a given adapter and page view to be used together. - * @param <PageViewT> (as in {@link MultiProfilePagerAdapter}). - * @param <SinglePageAdapterT> (as in {@link MultiProfilePagerAdapter}). - */ - 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); - } - - public static final int PROFILE_PERSONAL = 0; - public static final int PROFILE_WORK = 1; - - @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) - public @interface Profile {} - - 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<ProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems; - - private final EmptyStateProvider mEmptyStateProvider; - private final UserHandle mWorkProfileUserHandle; - private final UserHandle mCloneProfileUserHandle; - private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet. - - private Set<Integer> mLoadedPages; - private int mCurrentPage; - private OnProfileSelectedListener mOnProfileSelectedListener; - - protected MultiProfilePagerAdapter( - Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor, - AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder, - ImmutableList<SinglePageAdapterT> adapters, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - Supplier<ViewGroup> pageViewInflater, - Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { - mCurrentPage = defaultProfile; - mLoadedPages = new HashSet<>(); - mWorkProfileUserHandle = workProfileUserHandle; - mCloneProfileUserHandle = cloneProfileUserHandle; - mEmptyStateProvider = emptyStateProvider; - mWorkProfileQuietModeChecker = workProfileQuietModeChecker; - - mListAdapterExtractor = listAdapterExtractor; - mAdapterBinder = adapterBinder; - mPageViewInflater = pageViewInflater; - mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; - - ImmutableList.Builder<ProfileDescriptor<PageViewT, SinglePageAdapterT>> items = - new ImmutableList.Builder<>(); - for (SinglePageAdapterT adapter : adapters) { - items.add(createProfileDescriptor(adapter)); - } - mItems = items.build(); - } - - private ProfileDescriptor<PageViewT, SinglePageAdapterT> createProfileDescriptor( - SinglePageAdapterT adapter) { - return new ProfileDescriptor<>(mPageViewInflater.get(), adapter); - } - - public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { - mOnProfileSelectedListener = listener; - } - - /** - * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets - * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed - * page and rebuilds the list. - */ - public void setupViewPager(ViewPager viewPager) { - viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { - @Override - public void onPageSelected(int position) { - mCurrentPage = position; - if (!mLoadedPages.contains(position)) { - rebuildActiveTab(true); - mLoadedPages.add(position); - } - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfileSelected(position); - } - } - - @Override - public void onPageScrollStateChanged(int state) { - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageStateChanged(state); - } - } - }); - viewPager.setAdapter(this); - viewPager.setCurrentItem(mCurrentPage); - mLoadedPages.add(mCurrentPage); - } - - public void clearInactiveProfileCache() { - if (mLoadedPages.size() == 1) { - return; - } - mLoadedPages.remove(1 - mCurrentPage); - } - - @NonNull - @Override - public final ViewGroup instantiateItem(ViewGroup container, int position) { - setupListAdapter(position); - final ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem(position); - container.addView(descriptor.mRootView); - return descriptor.mRootView; - } - - @Override - public void destroyItem(ViewGroup container, int position, @NonNull Object view) { - container.removeView((View) view); - } - - @Override - public int getCount() { - return getItemCount(); - } - - public int getCurrentPage() { - return mCurrentPage; - } - - @VisibleForTesting - public UserHandle getCurrentUserHandle() { - return getActiveListAdapter().getUserHandle(); - } - - @Override - public boolean isViewFromObject(@NonNull View view, @NonNull Object object) { - return view == object; - } - - @Override - public CharSequence getPageTitle(int position) { - return null; - } - - public UserHandle getCloneUserHandle() { - return mCloneProfileUserHandle; - } - - /** - * Returns the {@link ProfileDescriptor} relevant to the given <code>pageIndex</code>. - * <ul> - * <li>For a device with only one user, <code>pageIndex</code> value of - * <code>0</code> would return the personal profile {@link ProfileDescriptor}.</li> - * <li>For a device with a work profile, <code>pageIndex</code> value of <code>0</code> would - * return the personal profile {@link ProfileDescriptor}, and <code>pageIndex</code> value of - * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li> - * </ul> - */ - private ProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) { - return mItems.get(pageIndex); - } - - public 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>. - * <p>For a device with a work profile returns <code>2</code>. - */ - public final int getItemCount() { - return mItems.size(); - } - - public final PageViewT getListViewForIndex(int index) { - return getItem(index).mView; - } - - /** - * Returns the adapter of the list view for the relevant page specified by - * <code>pageIndex</code>. - * <p>This method is meant to be implemented with an implementation-specific return type - * depending on the adapter type. - */ - @VisibleForTesting - public final SinglePageAdapterT getAdapterForIndex(int index) { - return getItem(index).mAdapter; - } - - /** - * Performs view-related initialization procedures for the adapter specified - * by <code>pageIndex</code>. - */ - public final void setupListAdapter(int pageIndex) { - mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); - } - - /** - * Returns the {@link ListAdapterT} instance of the profile that represents - * <code>userHandle</code>. If there is no such adapter for the specified - * <code>userHandle</code>, returns {@code null}. - * <p>For example, if there is a work profile on the device with user id 10, calling this method - * with <code>UserHandle.of(10)</code> returns the work profile {@link ListAdapterT}. - */ - @Nullable - public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { - if (getPersonalListAdapter().getUserHandle().equals(userHandle) - || userHandle.equals(getCloneUserHandle())) { - return getPersonalListAdapter(); - } else if ((getWorkListAdapter() != null) - && getWorkListAdapter().getUserHandle().equals(userHandle)) { - return getWorkListAdapter(); - } - return null; - } - - /** - * Returns the {@link ListAdapterT} instance of the profile that is currently visible - * to the user. - * <p>For example, if the user is viewing the work tab in the share sheet, this method returns - * the work profile {@link ListAdapterT}. - * @see #getInactiveListAdapter() - */ - @VisibleForTesting - public final ListAdapterT getActiveListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); - } - - /** - * If this is a device with a work profile, returns the {@link ListAdapterT} instance - * of the profile that is <b><i>not</i></b> currently visible to the user. Otherwise returns - * {@code null}. - * <p>For example, if the user is viewing the work tab in the share sheet, this method returns - * the personal profile {@link ListAdapterT}. - * @see #getActiveListAdapter() - */ - @VisibleForTesting - @Nullable - public final ListAdapterT getInactiveListAdapter() { - if (getCount() < 2) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); - } - - public final ListAdapterT getPersonalListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); - } - - @Nullable - public final ListAdapterT getWorkListAdapter() { - if (!hasAdapterForIndex(PROFILE_WORK)) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); - } - - public final SinglePageAdapterT getCurrentRootAdapter() { - return getAdapterForIndex(getCurrentPage()); - } - - public final PageViewT getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - @Nullable - public final PageViewT getInactiveAdapterView() { - if (getCount() < 2) { - return null; - } - return getListViewForIndex(1 - getCurrentPage()); - } - - /** - * Rebuilds the tab that is currently visible to the user. - * <p>Returns {@code true} if rebuild has completed. - */ - public boolean rebuildActiveTab(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); - boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); - Trace.endSection(); - return result; - } - - /** - * Rebuilds the tab that is not currently visible to the user, if such one exists. - * <p>Returns {@code true} if rebuild has completed. - */ - public boolean rebuildInactiveTab(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); - if (getItemCount() == 1) { - Trace.endSection(); - return false; - } - boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing); - Trace.endSection(); - return result; - } - - private int userHandleToPageIndex(UserHandle userHandle) { - if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { - return PROFILE_PERSONAL; - } else { - return PROFILE_WORK; - } - } - - private boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { - if (shouldSkipRebuild(activeListAdapter)) { - activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); - return false; - } - return activeListAdapter.rebuildList(doPostProcessing); - } - - private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) { - EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); - return emptyState != null && emptyState.shouldSkipDataRebuild(); - } - - private boolean hasAdapterForIndex(int pageIndex) { - return (pageIndex < getCount()); - } - - /** - * The empty state screens are shown according to their priority: - * <ol> - * <li>(highest priority) cross-profile disabled by policy (handled in - * {@link #rebuildTab(ListAdapterT, boolean)})</li> - * <li>no apps available</li> - * <li>(least priority) work is off</li> - * </ol> - * - * The intention is to prevent the user from having to turn - * the work profile on if there will not be any apps resolved - * anyway. - */ - public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { - final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); - - if (emptyState == null) { - return; - } - - emptyState.onEmptyStateShown(); - - View.OnClickListener clickListener = null; - - if (emptyState.getButtonClickListener() != null) { - clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { - ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( - userHandleToPageIndex(listAdapter.getUserHandle())); - descriptor.mEmptyStateUi.showSpinner(); - }); - } - - showEmptyState(listAdapter, emptyState, clickListener); - } - - /** - * Class to get user id of the current process - */ - public static class MyUserIdProvider { - /** - * @return user id of the current process - */ - public int getMyUserId() { - return UserHandle.myUserId(); - } - } - - protected void showEmptyState( - ListAdapterT activeListAdapter, - EmptyState emptyState, - View.OnClickListener buttonOnClick) { - ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.mRootView.findViewById( - com.android.internal.R.id.resolver_list).setVisibility(View.GONE); - descriptor.mEmptyStateUi.resetViewVisibilities(); - - ViewGroup emptyStateView = descriptor.getEmptyStateView(); - - 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); - 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); - } else { - 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); - - activeListAdapter.markTabLoaded(); - } - - /** - * Sets up the padding of the view containing the empty state screens. - * <p>This method is meant to be overridden so that subclasses can customize the padding. - */ - public void setupContainerPadding(View container) { - Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); - bottomPaddingOverride.ifPresent(paddingBottom -> - container.setPadding( - container.getPaddingLeft(), - container.getPaddingTop(), - container.getPaddingRight(), - paddingBottom)); - } - - public void showListView(ListAdapterT activeListAdapter) { - ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.mRootView.findViewById( - com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE); - descriptor.mEmptyStateUi.hide(); - } - - public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { - int count = listAdapter.getUnfilteredCount(); - return (count == 0 && listAdapter.getPlaceholderCount() == 0) - || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && mWorkProfileQuietModeChecker.get()); - } - - // 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 ProfileDescriptor<PageViewT, SinglePageAdapterT> { - final ViewGroup mRootView; - final EmptyStateUiHelper mEmptyStateUi; - - // TODO: post-refactoring, we may not need to retain these ivars directly (since they may - // be encapsulated within the `EmptyStateUiHelper`?). - private final ViewGroup mEmptyStateView; - - private final SinglePageAdapterT mAdapter; - private final PageViewT mView; - - ProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) { - mRootView = rootView; - mAdapter = adapter; - mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); - mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); - mEmptyStateUi = new EmptyStateUiHelper(rootView); - } - - protected ViewGroup getEmptyStateView() { - return mEmptyStateView; - } - } - - /** Listener interface for changes between the per-profile UI tabs. */ - public interface OnProfileSelectedListener { - /** - * Callback for when the user changes the active tab from personal to work or vice versa. - * <p>This callback is only called when the intent resolver or share sheet shows - * the work and personal profiles. - * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or - * {@link #PROFILE_WORK} if the work profile was selected. - */ - void onProfileSelected(int profileIndex); - - - /** - * Callback for when the scroll state changes. Useful for discovering when the user begins - * dragging, when the pager is automatically settling to the current page, or when it is - * fully stopped/idle. - * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} - * or {@link ViewPager#SCROLL_STATE_SETTLING} - * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged - */ - void onProfilePageStateChanged(int state); - } - - /** - * Listener for when the user switches on the work profile from the work tab. - */ - public interface OnSwitchOnWorkSelectedListener { - /** - * Callback for when the user switches on the work profile from the work tab. - */ - void onSwitchOnWorkSelected(); - } -} diff --git a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt b/java/src/com/android/intentresolver/ProfileAvailability.kt index 4d689724..43982727 100644 --- a/java/src/com/android/intentresolver/v2/ProfileAvailability.kt +++ b/java/src/com/android/intentresolver/ProfileAvailability.kt @@ -14,38 +14,62 @@ * limitations under the License. */ -package com.android.intentresolver.v2 +package com.android.intentresolver -import com.android.intentresolver.v2.domain.interactor.UserInteractor -import com.android.intentresolver.v2.shared.model.Profile -import kotlin.time.Duration.Companion.seconds +import androidx.annotation.MainThread +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.shared.model.Profile +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.runBlocking /** Provides availability status for profiles */ +@JavaInterop class ProfileAvailability( + private val userInteractor: UserInteractor, private val scope: CoroutineScope, - private val userInteractor: UserInteractor + private val background: CoroutineDispatcher, ) { - private val availability = - userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, mapOf()) - /** Used by WorkProfilePausedEmptyStateProvider */ var waitingToEnableProfile = false private set + /** Set by ChooserActivity to call onWorkProfileStatusUpdated */ + var onProfileStatusChange: Runnable? = null + private var waitJob: Job? = null + /** Query current profile availability. An unavailable profile is one which is not active. */ - fun isAvailable(profile: Profile) = availability.value[profile] ?: false + @MainThread + fun isAvailable(profile: Profile?): Boolean { + return runBlocking(background) { + userInteractor.availability.map { it[profile] == true }.first() + } + } + + /** + * The number of profiles which are visible. All profiles count except for private which is + * hidden when locked. + */ + fun visibleProfileCount() = + runBlocking(background) { + val availability = userInteractor.availability.first() + val profiles = userInteractor.profiles.first() + profiles + .filter { + when (it.type) { + Profile.Type.PRIVATE -> availability[it] == true + else -> true + } + } + .size + } /** Used by WorkProfilePausedEmptyStateProvider */ fun requestQuietModeState(profile: Profile, quietMode: Boolean) { @@ -61,14 +85,14 @@ class ProfileAvailability( waitingToEnableProfile = true waitJob?.cancel() - val job = scope.launch { - // Wait for the profile to become available - // Wait for the profile to be enabled, then clear this flag - userInteractor.availability.filter { it[profile] == true }.first() - waitingToEnableProfile = false - } + val job = + scope.launch { + // Wait for the profile to become available + userInteractor.availability.filter { it[profile] == true }.first() + } job.invokeOnCompletion { waitingToEnableProfile = false + onProfileStatusChange?.run() } waitJob = job } @@ -76,4 +100,4 @@ class ProfileAvailability( // Apply the change scope.launch { userInteractor.updateState(profile, enableProfile) } } -}
\ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/ProfileHelper.kt b/java/src/com/android/intentresolver/ProfileHelper.kt new file mode 100644 index 00000000..53a873a3 --- /dev/null +++ b/java/src/com/android/intentresolver/ProfileHelper.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.os.UserHandle +import androidx.annotation.MainThread +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.IntentResolverFlags +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.User +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +@JavaInterop +@MainThread +class ProfileHelper +@Inject +constructor( + interactor: UserInteractor, + private val scope: CoroutineScope, + private val background: CoroutineDispatcher, + private val flags: IntentResolverFlags, +) { + private val launchedByHandle: UserHandle = interactor.launchedAs + + val launchedAsProfile by lazy { + runBlocking(background) { interactor.launchedAsProfile.first() } + } + val profiles by lazy { runBlocking(background) { interactor.profiles.first() } } + + // Map UserHandle back to a user within launchedByProfile + private val launchedByUser: User = + when (launchedByHandle) { + launchedAsProfile.primary.handle -> launchedAsProfile.primary + launchedAsProfile.clone?.handle -> requireNotNull(launchedAsProfile.clone) + else -> error("launchedByUser must be a member of launchedByProfile") + } + val launchedAsProfileType: Profile.Type = launchedAsProfile.type + + val personalProfile = profiles.single { it.type == Profile.Type.PERSONAL } + val workProfile = profiles.singleOrNull { it.type == Profile.Type.WORK } + val privateProfile = profiles.singleOrNull { it.type == Profile.Type.PRIVATE } + + val personalHandle = personalProfile.primary.handle + val workHandle = workProfile?.primary?.handle + val privateHandle = privateProfile?.primary?.handle + val cloneHandle = personalProfile.clone?.handle + + val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone + + val cloneUserPresent = personalProfile.clone != null + val workProfilePresent = workProfile != null + val privateProfilePresent = privateProfile != null + + // Name retained for ease of review, to be renamed later + val tabOwnerUserHandleForLaunch = + if (launchedByUser.role == User.Role.CLONE) { + // When started by clone user, return the profile owner instead + launchedAsProfile.primary.handle + } else { + // Otherwise the launched user is used + launchedByUser.handle + } + + fun findProfile(handle: UserHandle): Profile? { + return profiles.firstOrNull { it.primary.handle == handle || it.clone?.handle == handle } + } + + fun findProfileType(handle: UserHandle): Profile.Type? = findProfile(handle)?.type + + // Name retained for ease of review, to be renamed later + fun getQueryIntentsHandle(handle: UserHandle): UserHandle? { + return if (isLaunchedAsCloneProfile && handle == personalHandle) { + cloneHandle + } else { + handle + } + } +} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 0331c33e..a402fc72 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -16,39 +16,25 @@ package com.android.intentresolver; -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; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; -import static android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.content.PermissionChecker.PID_UNKNOWN; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; -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 static androidx.lifecycle.LifecycleKt.getCoroutineScope; + +import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; -import android.app.Activity; -import android.app.ActivityManager; +import static java.util.Objects.requireNonNull; + import android.app.ActivityThread; import android.app.VoiceInteractor.PickOptionRequest; import android.app.VoiceInteractor.PickOptionRequest.Option; import android.app.VoiceInteractor.Prompt; import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.PermissionChecker; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -56,7 +42,6 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.UserInfo; import android.content.res.Configuration; -import android.content.res.TypedArray; import android.graphics.Insets; import android.net.Uri; import android.os.Build; @@ -67,7 +52,6 @@ import android.os.StrictMode; import android.os.Trace; import android.os.UserHandle; import android.os.UserManager; -import android.provider.MediaStore; import android.provider.Settings; import android.stats.devicepolicy.DevicePolicyEnums; import android.text.TextUtils; @@ -89,48 +73,64 @@ import android.widget.ImageView; import android.widget.ListView; import android.widget.Space; import android.widget.TabHost; -import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.annotation.UiThread; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.CreationExtras; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.MultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.MultiProfilePagerAdapter.Profile; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.data.repository.DevicePolicyResources; +import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; -import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.inject.Background; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.profiles.OnProfileSelectedListener; +import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.profiles.ResolverMultiProfilePagerAdapter; +import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.Profile; +import com.android.intentresolver.ui.ActionTitle; +import com.android.intentresolver.ui.ProfilePagerResources; +import com.android.intentresolver.ui.model.ActivityModel; +import com.android.intentresolver.ui.model.ResolverRequest; +import com.android.intentresolver.ui.viewmodel.ResolverViewModel; 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.google.common.collect.ImmutableList; + +import dagger.hilt.android.AndroidEntryPoint; + +import kotlin.Pair; + +import kotlinx.coroutines.CoroutineDispatcher; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.function.Supplier; + +import javax.inject.Inject; /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is @@ -138,47 +138,34 @@ import java.util.function.Supplier; * frameworks/base/core/java/com/android/internal/app/ResolverActivity.java for that), the full * migration is not complete. */ -@UiThread -public class ResolverActivity extends FragmentActivity implements +@AndroidEntryPoint(FragmentActivity.class) +public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { - public ResolverActivity() { - mIsIntentPicker = getClass().equals(ResolverActivity.class); - } - - protected ResolverActivity(boolean isIntentPicker) { - mIsIntentPicker = isIntentPicker; - } - - /** - * Whether to enable a launch mode that is safe to use when forwarding intents received from - * applications and running in system processes. This mode uses Activity.startActivityAsCaller - * instead of the normal Activity.startActivity for launching the activity selected - * by the user. - */ - private boolean mSafeForwardingMode; + @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; + @Inject public UserInteractor mUserInteractor; + @Inject public ResolverHelper mResolverHelper; + @Inject public PackageManager mPackageManager; + @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public ProfilePagerResources mProfilePagerResources; + @Inject public IntentForwarding mIntentForwarding; + @Inject public FeatureFlags mFeatureFlags; + + private ResolverViewModel mViewModel; + private ResolverRequest mRequest; + private ProfileHelper mProfiles; + private ProfileAvailability mProfileAvailability; + protected TargetDataLoader mTargetDataLoader; + private boolean mResolvingHome; private Button mAlwaysButton; private Button mOnceButton; protected View mProfileView; private int mLastSelected = AbsListView.INVALID_POSITION; - private boolean mResolvingHome = false; - private String mProfileSwitchMessage; private int mLayoutId; - @VisibleForTesting - protected final ArrayList<Intent> mIntents = new ArrayList<>(); private PickTargetOptionRequest mPickOptionRequest; - private String mReferrerPackage; - private CharSequence mTitle; - private int mDefaultTitleResId; // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. - private final boolean mIsIntentPicker; - - // Whether or not this activity supports choosing a default handler for the intent. - @VisibleForTesting - protected boolean mSupportsAlwaysUseOption; protected ResolverDrawerLayout mResolverDrawerLayout; - protected PackageManager mPm; private static final String TAG = "ResolverActivity"; private static final boolean DEBUG = false; @@ -189,150 +176,32 @@ public class ResolverActivity extends FragmentActivity implements protected Insets mSystemWindowInsets = null; private Space mFooterSpacer = null; - /** See {@link #setRetainInOnStop}. */ - private boolean mRetainInOnStop; - 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; + private final boolean mWorkProfileHasBeenEnabled = false; - private static final String TAB_TAG_PERSONAL = "personal"; - private static final String TAB_TAG_WORK = "work"; + protected static final String TAB_TAG_PERSONAL = "personal"; + protected static final String TAB_TAG_WORK = "work"; private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; - private TargetDataLoader mTargetDataLoader; - - @VisibleForTesting - protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; - - protected WorkProfileAvailabilityManager mWorkProfileAvailability; + protected ResolverMultiProfilePagerAdapter mMultiProfilePagerAdapter; - // Intent extra for connected audio devices - public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; - - /** - * Integer extra to indicate which profile should be automatically selected. - * <p>Can only be used if there is a work profile. - * <p>Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}. - */ - protected static final String EXTRA_SELECTED_PROFILE = - "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE"; - - /** - * {@link UserHandle} extra to indicate the user of the user that the starting intent - * originated from. - * <p>This is not necessarily the same as {@link #getUserId()} or {@link UserHandle#myUserId()}, - * as there are edge cases when the intent resolver is launched in the other profile. - * For example, when we have 0 resolved apps in current profile and multiple resolved - * apps in the other profile, opening a link from the current profile launches the intent - * resolver in the other one. b/148536209 for more info. - */ - static final String EXTRA_CALLING_USER = - "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; - - protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; - protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; + public static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; + public static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; private UserHandle mHeaderCreatorUser; - // User handle annotations are lazy-initialized to ensure that they're computed exactly once - // (even though they can't be computed prior to activity creation). - // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or - // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a - // new component whose lifecycle is limited to the "created" Activity (so that we can just hold - // the annotations as a `final` ivar, which is a better way to show immutability). - private Supplier<AnnotatedUserHandles> mLazyAnnotatedUserHandles = () -> { - final AnnotatedUserHandles result = computeAnnotatedUserHandles(); - mLazyAnnotatedUserHandles = () -> result; - return result; - }; - - // This method is called exactly once during creation to compute the immutable annotations - // accessible through the lazy supplier {@link mLazyAnnotatedUserHandles}. - // TODO: this is only defined so that tests can provide an override that injects fake - // annotations. Dagger could provide a cleaner model for our testing/injection requirements. - @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) - protected AnnotatedUserHandles computeAnnotatedUserHandles() { - return AnnotatedUserHandles.forShareActivity(this); - } - @Nullable private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - - private enum ActionTitle { - VIEW(Intent.ACTION_VIEW, - R.string.whichViewApplication, - R.string.whichViewApplicationNamed, - R.string.whichViewApplicationLabel), - EDIT(Intent.ACTION_EDIT, - R.string.whichEditApplication, - R.string.whichEditApplicationNamed, - R.string.whichEditApplicationLabel), - SEND(Intent.ACTION_SEND, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - SENDTO(Intent.ACTION_SENDTO, - R.string.whichSendToApplication, - R.string.whichSendToApplicationNamed, - R.string.whichSendToApplicationLabel), - SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, - R.string.whichImageCaptureApplication, - R.string.whichImageCaptureApplicationNamed, - R.string.whichImageCaptureApplicationLabel), - DEFAULT(null, - R.string.whichApplication, - R.string.whichApplicationNamed, - R.string.whichApplicationLabel), - HOME(Intent.ACTION_MAIN, - R.string.whichHomeApplication, - R.string.whichHomeApplicationNamed, - R.string.whichHomeApplicationLabel); - - // titles for layout that deals with http(s) intents - public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; - public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; - public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; - public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; - - public final String action; - public final int titleRes; - public final int namedTitleRes; - public final @StringRes int labelRes; - - ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { - this.action = action; - this.titleRes = titleRes; - this.namedTitleRes = namedTitleRes; - this.labelRes = labelRes; - } - - public static ActionTitle forAction(String action) { - for (ActionTitle title : values()) { - if (title != HOME && action != null && action.equals(title.action)) { - return title; - } - } - return DEFAULT; - } - } - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override public void onSomePackagesChanged() { listAdapter.handlePackagesChanged(); - updateProfileViewButton(); } @Override @@ -344,123 +213,169 @@ public class ResolverActivity extends FragmentActivity implements }; } - @Override - protected void onCreate(Bundle savedInstanceState) { - // Use a specialized prompt when we're handling the 'Home' app startActivity() - final Intent intent = makeMyIntent(); - final Set<String> categories = intent.getCategories(); - if (Intent.ACTION_MAIN.equals(intent.getAction()) - && categories != null - && categories.size() == 1 - && categories.contains(Intent.CATEGORY_HOME)) { - // Note: this field is not set to true in the compatibility version. - mResolvingHome = true; - } - - onCreate( - savedInstanceState, - intent, - /* additionalTargets= */ null, - /* title= */ null, - /* defaultTitleRes= */ 0, - /* initialIntents= */ null, - /* resolutionList= */ null, - /* supportsAlwaysUseOption= */ true, - createIconLoader(), - /* safeForwardingMode= */ true); + protected ActivityModel createActivityModel() { + return ActivityModel.createFrom(this); } - /** - * Compatibility version for other bundled services that use this overload without - * a default title resource - */ - protected void onCreate( - Bundle savedInstanceState, - Intent intent, - CharSequence title, - Intent[] initialIntents, - List<ResolveInfo> resolutionList, - boolean supportsAlwaysUseOption, - boolean safeForwardingMode) { - onCreate( - savedInstanceState, - intent, - null, - title, - 0, - initialIntents, - resolutionList, - supportsAlwaysUseOption, - createIconLoader(), - safeForwardingMode); + @NonNull + @Override + public CreationExtras getDefaultViewModelCreationExtras() { + return addDefaultArgs( + super.getDefaultViewModelCreationExtras(), + new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, createActivityModel())); } - protected void onCreate( - Bundle savedInstanceState, - Intent intent, - Intent[] additionalTargets, - CharSequence title, - int defaultTitleRes, - Intent[] initialIntents, - List<ResolveInfo> resolutionList, - boolean supportsAlwaysUseOption, - TargetDataLoader targetDataLoader, - boolean safeForwardingMode) { - setTheme(appliedThemeResId()); + @Override + protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + Log.i(TAG, "onCreate"); + setTheme(R.style.Theme_DeviceDefault_Resolver); + mResolverHelper.setInitializer(this::initialize); + } - // Determine whether we should show that intent is forwarded - // from managed profile to owner or other way around. - setProfileSwitchMessage(intent.getContentUserHint()); + @Override + protected final void onStart() { + super.onStart(); + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + } + + @Override + protected void onStop() { + super.onStop(); - // Force computation of user handle annotations in order to validate the caller ID. (See the - // associated TODO comment to explain why this is structured as a lazy computation.) - AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get(); + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); - mWorkProfileAvailability = createWorkProfileAvailabilityManager(); + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mResolvingHome) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } + } - mPm = getPackageManager(); + @Override + protected final void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } + } - mReferrerPackage = getReferrerPackageName(); + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + mRegistered = true; + } + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + } - // The initial intent must come before any other targets that are to be added. - mIntents.add(0, new Intent(intent)); - if (additionalTargets != null) { - Collections.addAll(mIntents, additionalTargets); + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mMultiProfilePagerAdapter != null + && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { + mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); } + } + + private void initialize() { + mViewModel = new ViewModelProvider(this).get(ResolverViewModel.class); + mRequest = mViewModel.getRequest().getValue(); - mTitle = title; - mDefaultTitleResId = defaultTitleRes; + mProfiles = new ProfileHelper( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher, + mFeatureFlags); - mSupportsAlwaysUseOption = supportsAlwaysUseOption; - mSafeForwardingMode = safeForwardingMode; - mTargetDataLoader = targetDataLoader; + mProfileAvailability = new ProfileAvailability( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher); + + mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); + + mResolvingHome = mRequest.isResolvingHome(); + mTargetDataLoader = new DefaultTargetDataLoader( + this, + getLifecycle(), + mRequest.isAudioCaptureDevice()); // 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 // turn this off when running under voice interaction, since it results in // a more complicated UI that the current voice interaction flow is not able - // to handle. We also turn it off when the work tab is shown to simplify the UX. + // to handle. We also turn it off when multiple tabs are shown to simplify the UX. // We also turn it off when clonedProfile is present on the device, because we might have // different "last chosen" activities in the different profiles, and PackageManager doesn't // provide any more information to help us select between them. - boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() - && !shouldShowTabs() && !hasCloneProfile(); + boolean filterLastUsed = !isVoiceInteraction() + && !mProfiles.getWorkProfilePresent() && !mProfiles.getCloneUserPresent(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); - if (configureContentView(targetDataLoader)) { + new Intent[0], + /* resolutionList = */ mRequest.getResolutionList(), + filterLastUsed + ); + if (configureContentView(mTargetDataLoader)) { return; } mPersonalPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getPersonalListAdapter()); mPersonalPackageMonitor.register( - this, getMainLooper(), getAnnotatedUserHandles().personalProfileUserHandle, false); - if (shouldShowTabs()) { + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false + ); + if (mProfiles.getWorkProfilePresent()) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); mWorkPackageMonitor.register( - this, getMainLooper(), getAnnotatedUserHandles().workProfileUserHandle, false); + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false + ); } mRegistered = true; @@ -474,7 +389,7 @@ public class ResolverActivity extends FragmentActivity implements } }); - boolean hasTouchScreen = getPackageManager() + boolean hasTouchScreen = mPackageManager .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); if (isVoiceInteraction() || !hasTouchScreen) { @@ -487,13 +402,7 @@ public class ResolverActivity extends FragmentActivity implements mResolverDrawerLayout = rdl; } - - mProfileView = findViewById(com.android.internal.R.id.profile_button); - if (mProfileView != null) { - mProfileView.setOnClickListener(this::onProfileClick); - updateProfileViewButton(); - } - + Intent intent = mViewModel.getRequest().getValue().getIntent(); final Set<String> categories = intent.getCategories(); MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED @@ -502,65 +411,47 @@ public class ResolverActivity extends FragmentActivity implements + (categories != null ? Arrays.toString(categories.toArray()) : "")); } - protected MultiProfilePagerAdapter createMultiProfilePagerAdapter( + private void restore(@Nullable Bundle savedInstanceState) { + if (savedInstanceState != null) { + // onRestoreInstanceState + resetButtonBar(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + } + + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + + protected ResolverMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, List<ResolveInfo> resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - MultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; - if (shouldShowTabs()) { + boolean filterLastUsed) { + ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; + if (mProfiles.getWorkProfilePresent()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } else { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } return resolverMultiProfilePagerAdapter; } protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); + boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); if (!shouldShowNoCrossProfileIntentsEmptyState) { // Implementation that doesn't show any blockers return new EmptyStateProvider() {}; } - - final 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 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( - getAnnotatedUserHandles().personalProfileUserHandle, - noWorkToPersonalEmptyState, - noPersonalToWorkEmptyState, + mProfiles, + mDevicePolicyResources, createCrossProfileIntentsChecker(), - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - - protected int appliedThemeResId() { - return R.style.Theme_DeviceDefault_Resolver; + /* isShare= */ false); } /** @@ -572,9 +463,7 @@ public class ResolverActivity extends FragmentActivity implements if (useLayoutWithDefault()) return true; View buttonBar = findViewById(com.android.internal.R.id.button_bar); - if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true; - - return false; + return buttonBar == null || buttonBar.getVisibility() == View.GONE; } protected void applyFooterView(int height) { @@ -582,12 +471,12 @@ public class ResolverActivity extends FragmentActivity implements mFooterSpacer = new Space(getApplicationContext()); } else { ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().removeFooterView(mFooterSpacer); + .getActiveAdapterView().removeFooterView(mFooterSpacer); } mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, - mSystemWindowInsets.bottom)); + mSystemWindowInsets.bottom)); ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().addFooterView(mFooterSpacer); + .getActiveAdapterView().addFooterView(mFooterSpacer); } protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { @@ -613,10 +502,10 @@ public class ResolverActivity extends FragmentActivity implements } @Override - public void onConfigurationChanged(@NonNull Configuration newConfig) { + public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() + if (mProfiles.getWorkProfilePresent() && !useLayoutWithDefault() && !shouldUseMiniResolver()) { updateIntentPickerPaddings(); } @@ -631,52 +520,7 @@ public class ResolverActivity extends FragmentActivity implements return R.layout.resolver_list; } - @Override - protected void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mResolvingHome && !mRetainInOnStop) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - // TODO: should we clean up the work-profile manager before we potentially finish() above? - mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (!isChangingConfigurations() && mPickOptionRequest != null) { - mPickOptionRequest.cancel(); - } - if (mMultiProfilePagerAdapter != null - && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { - mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); - } - } - + // referenced by layout XML: android:onClick="onButtonClick" public void onButtonClick(View v) { final int id = v.getId(); ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); @@ -695,9 +539,9 @@ public class ResolverActivity extends FragmentActivity implements ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() .resolveInfoForPosition(which, hasIndexBeenFiltered); if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { + String launcherName = ri.activityInfo.loadLabel(mPackageManager).toString(); Toast.makeText(this, - getWorkProfileNotSupportedMsg( - ri.activityInfo.loadLabel(getPackageManager()).toString()), + mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), Toast.LENGTH_LONG).show(); return; } @@ -708,15 +552,12 @@ public class ResolverActivity extends FragmentActivity implements return; } if (onTargetSelected(target, always)) { - if (always && mSupportsAlwaysUseOption) { + if (always) { MetricsLogger.action( this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); - } else if (mSupportsAlwaysUseOption) { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); } else { MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP); + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); } MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() @@ -726,9 +567,6 @@ public class ResolverActivity extends FragmentActivity implements } } - /** - * Replace me in subclasses! - */ @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { return defIntent; @@ -737,7 +575,7 @@ public class ResolverActivity extends FragmentActivity implements protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { final ItemClickListener listener = new ItemClickListener(); setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); - if (shouldShowTabs() && mIsIntentPicker) { + if (mProfiles.getWorkProfilePresent()) { final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); if (rdl != null) { rdl.setMaxCollapsedHeight(getResources() @@ -752,9 +590,9 @@ public class ResolverActivity extends FragmentActivity implements final ResolveInfo ri = target.getResolveInfo(); final Intent intent = target != null ? target.getResolvedIntent() : null; - if (intent != null && (mSupportsAlwaysUseOption - || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) - && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { + if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/ + && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() + != null) { // Build a reasonable intent filter, based on what matched. IntentFilter filter = new IntentFilter(); Intent filterIntent; @@ -796,7 +634,7 @@ public class ResolverActivity extends FragmentActivity implements // or "content:" schemes (see IntentFilter for the reason). if (cat != IntentFilter.MATCH_CATEGORY_TYPE || (!"file".equals(data.getScheme()) - && !"content".equals(data.getScheme()))) { + && !"content".equals(data.getScheme()))) { filter.addDataScheme(data.getScheme()); // Look through the resolved filter to determine which part @@ -854,7 +692,7 @@ public class ResolverActivity extends FragmentActivity implements } int bestMatch = 0; - for (int i=0; i<N; i++) { + for (int i = 0; i < N; i++) { ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter() .getUnfilteredResolveList().get(i).getResolveInfoAt(0); set[i] = new ComponentName(r.activityInfo.packageName, @@ -872,7 +710,7 @@ public class ResolverActivity extends FragmentActivity implements if (always) { final int userId = getUserId(); - final PackageManager pm = getPackageManager(); + final PackageManager pm = mPackageManager; // Set the preferred Activity pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); @@ -881,7 +719,8 @@ public class ResolverActivity extends FragmentActivity implements // Set default Browser if needed final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId); if (TextUtils.isEmpty(packageName)) { - pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId); + pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, + userId); } } } else { @@ -895,21 +734,11 @@ public class ResolverActivity extends FragmentActivity implements } } - if (target != null) { - safelyStartActivity(target); + safelyStartActivity(target); - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - if (target.isSuspended()) { - return false; - } - } - - return true; - } - - public void onActivityStarted(TargetInfo cti) { - // Do nothing + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + return !target.isSuspended(); } @Override // ResolverListCommunicator @@ -921,58 +750,65 @@ public class ResolverActivity extends FragmentActivity implements return !target.isSuspended(); } - // TODO: this method takes an unused `UserHandle` because the override in `ChooserActivity` uses - // that data to set up other components as dependencies of the controller. In reality, these - // methods don't require polymorphism, because they're only invoked from within their respective - // concrete class; `ResolverActivity` will never call this method expecting to get a - // `ChooserListController` (subclass) result, because `ResolverActivity` only invokes this - // method as part of handling `createMultiProfilePagerAdapter()`, which is itself overridden in - // `ChooserActivity`. A future refactoring could better express the coupling between the adapter - // and controller types; in the meantime, structuring as an override (with matching signatures) - // shows that these methods are *structurally* related, and helps to prevent any regressions in - // the future if resolver *were* to make any (non-overridden) calls to a version that used a - // different signature (and thus didn't return the subclass type). @VisibleForTesting protected ResolverListController createListController(UserHandle userHandle) { ResolverRankerServiceResolverComparator resolverComparator = new ResolverRankerServiceResolverComparator( this, - getTargetIntent(), - getReferrerPackageName(), + mRequest.getIntent(), + mViewModel.getActivityModel().getReferrerPackage(), null, null, getResolverRankerServiceUserHandleList(userHandle), null); return new ResolverListController( this, - mPm, - getTargetIntent(), - getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp, + mPackageManager, + mRequest.getIntent(), + mViewModel.getActivityModel().getReferrerPackage(), + mViewModel.getActivityModel().getLaunchedFromUid(), resolverComparator, - getQueryIntentsUser(userHandle)); + mProfiles.getQueryIntentsHandle(userHandle)); } /** * Finishing procedures to be performed after the list has been rebuilt. * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList. - * @param rebuildCompleted + * * @return <code>true</code> if the activity is finishing and creation should halt. */ protected boolean postRebuildList(boolean rebuildCompleted) { return postRebuildListInternal(rebuildCompleted); } - void onHorizontalSwipeStateChanged(int state) {} - /** * Callback called when user changes the profile tab. - * <p>This method is intended to be overridden by subclasses. */ - protected void onProfileTabSelected() { } + /* TODO: consider merging with the customized considerations of our implemented + * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions + * between the respective listener callbacks would occur in the triggering patterns during init + * (when the `OnProfileSelectedListener` is registered after a possible tab-change), or possibly + * if there's some way to trigger an update in one model but not the other. If there's an + * initialization dependency, we can probably reason about it with confidence. If there's a + * discrepancy between the `TabHost` and pager-adapter data models, that inconsistency is + * likely to be a bug that would benefit from consolidation. + */ + protected void onProfileTabSelected(int currentPage) { + setupViewVisibilities(); + maybeLogProfileChange(); + if (mProfiles.getWorkProfilePresent()) { + // The device policy logger is only concerned with sessions that include a work profile. + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(currentPage) + .setStrings(getMetricsCategory()) + .write(); + } + } /** * Add a label to signify that the user can pick a different app. + * * @param adapter The adapter used to provide data to item views. */ public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { @@ -982,7 +818,7 @@ public class ResolverActivity extends FragmentActivity implements stub.setVisibility(View.VISIBLE); TextView textView = (TextView) LayoutInflater.from(this).inflate( R.layout.resolver_different_item_header, null, false); - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { textView.setGravity(Gravity.CENTER); } stub.addView(textView); @@ -990,9 +826,6 @@ public class ResolverActivity extends FragmentActivity implements } protected void resetButtonBar() { - if (!mSupportsAlwaysUseOption) { - return; - } final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); if (buttonLayout == null) { Log.e(TAG, "Layout unexpectedly does not have a button bar"); @@ -1034,55 +867,24 @@ public class ResolverActivity extends FragmentActivity implements } @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(getAnnotatedUserHandles().workProfileUserHandle) - && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { - // We have just turned on the work profile and entered the pass code to start it, - // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no - // point in reloading the list now, since the work profile user is still - // turning on. - return; - } - boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true); - if (listRebuilt) { - ResolverListAdapter activeListAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); - activeListAdapter.notifyDataSetChanged(); - if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) { - // We no longer have any items... just finish the activity. - finish(); - } - } - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); + public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (!mMultiProfilePagerAdapter.onHandlePackagesChanged( + listAdapter, + mProfileAvailability.getWaitingToEnableProfile())) { + // We no longer have any items... just finish the activity. + finish(); } } protected void maybeLogProfileChange() {} - // @NonFinalForTesting - @VisibleForTesting - protected MyUserIdProvider createMyUserIdProvider() { - return new MyUserIdProvider(); - } - - // @NonFinalForTesting @VisibleForTesting protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { return new CrossProfileIntentsChecker(getContentResolver()); } - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - return new WorkProfileAvailabilityManager( - getSystemService(UserManager.class), - getAnnotatedUserHandles().workProfileUserHandle, - this::onWorkProfileStatusUpdated); - } - - protected void onWorkProfileStatusUpdated() { - if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( - getAnnotatedUserHandles().workProfileUserHandle)) { + private void onWorkProfileStatusUpdated() { + if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); @@ -1097,11 +899,8 @@ public class ResolverActivity extends FragmentActivity implements Intent[] initialIntents, List<ResolveInfo> resolutionList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) - ? getAnnotatedUserHandles().cloneProfileUserHandle : userHandle; + UserHandle userHandle) { + UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); return new ResolverListAdapter( context, payloadIntents, @@ -1110,33 +909,10 @@ public class ResolverActivity extends FragmentActivity implements filterLastUsed, createListController(userHandle), userHandle, - getTargetIntent(), + mRequest.getIntent(), this, initialIntentsUserSpace, - targetDataLoader); - } - - private TargetDataLoader createIconLoader() { - Intent startIntent = getIntent(); - boolean isAudioCaptureDevice = - startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); - return new DefaultTargetDataLoader(this, getLifecycle(), isAudioCaptureDevice); - } - - private LatencyTracker getLatencyTracker() { - return LatencyTracker.getInstance(this); - } - - /** - * Get the string resource to be used as a label for the link to the resolver activity for an - * action. - * - * @param action The action to resolve - * - * @return The string resource to be used as a label - */ - public static @StringRes int getLabelRes(String action) { - return ActionTitle.forAction(action).labelRes; + mTargetDataLoader); } protected final EmptyStateProvider createEmptyStateProvider( @@ -1144,8 +920,10 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mWorkProfileAvailability, + new WorkProfilePausedEmptyStateProvider( + this, + mProfiles, + mProfileAvailability, /* onSwitchOnWorkSelectedListener= */ () -> { if (mOnSwitchOnWorkSelectedListener != null) { @@ -1154,12 +932,11 @@ public class ResolverActivity extends FragmentActivity implements }, getMetricsCategory()); - final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( - this, - workProfileUserHandle, - getAnnotatedUserHandles().personalProfileUserHandle, + EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + mProfiles, + mProfileAvailability, getMetricsCategory(), - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch + mProfilePagerResources ); // Return composite provider, the order matters (the higher, the more priority) @@ -1170,76 +947,52 @@ public class ResolverActivity extends FragmentActivity implements ); } - private Intent makeMyIntent() { - Intent intent = new Intent(getIntent()); - intent.setComponent(null); - // The resolver activity is set to be hidden from recent tasks. - // we don't want this attribute to be propagated to the next activity - // being launched. Note that if the original Intent also had this - // flag set, we are now losing it. That should be a very rare case - // and we can live with this. - intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); - - // If FLAG_ACTIVITY_LAUNCH_ADJACENT was set, ResolverActivity was opened in the alternate - // side, which means we want to open the target app on the same side as ResolverActivity. - if ((intent.getFlags() & FLAG_ACTIVITY_LAUNCH_ADJACENT) != 0) { - intent.setFlags(intent.getFlags() & ~FLAG_ACTIVITY_LAUNCH_ADJACENT); - } - return intent; - } - - /** - * Call {@link Activity#onCreate} without initializing anything further. This should - * only be used when the activity is about to be immediately finished to avoid wasting - * initializing steps and leaking resources. - */ - protected final void super_onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - private ResolverMultiProfilePagerAdapter - createResolverMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List<ResolveInfo> resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - ResolverListAdapter adapter = createResolverListAdapter( + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean filterLastUsed) { + ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mRequest.getPayloadIntents(), initialIntents, resolutionList, filterLastUsed, - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ mProfiles.getPersonalHandle() + ); return new ResolverMultiProfilePagerAdapter( /* context */ this, - adapter, + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter)), createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, + /* defaultProfile= */ PROFILE_PERSONAL, /* workProfileUserHandle= */ null, - getAnnotatedUserHandles().cloneProfileUserHandle); + mProfiles.getCloneHandle()); } private UserHandle getIntentUser() { - return getIntent().hasExtra(EXTRA_CALLING_USER) - ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + return Objects.requireNonNullElse(mRequest.getCallingUser(), + mProfiles.getTabOwnerUserHandleForLaunch()); } private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, List<ResolveInfo> resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { // In the edge case when we have 0 apps in the current profile and >1 apps in the other, // 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 = getIntentUser(); - if (!getAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { - if (getAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { + if (!mProfiles.getTabOwnerUserHandleForLaunch().equals(intentUser)) { + if (mProfiles.getPersonalHandle().equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; - } else if (getAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { + } else if (mProfiles.getWorkHandle().equals(intentUser)) { selectedProfile = PROFILE_WORK; } } else { @@ -1253,95 +1006,70 @@ public class ResolverActivity extends FragmentActivity implements // resolver list. So filterLastUsed should be false for the other profile. ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mRequest.getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() - == getAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), - /* userHandle */ getAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); - UserHandle workProfileUserHandle = getAnnotatedUserHandles().workProfileUserHandle; + == mProfiles.getPersonalHandle().getIdentifier()), + /* userHandle */ mProfiles.getPersonalHandle() + ); + UserHandle workProfileUserHandle = mProfiles.getWorkHandle(); ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mRequest.getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), - /* userHandle */ workProfileUserHandle, - targetDataLoader); + /* userHandle */ workProfileUserHandle + ); return new ResolverMultiProfilePagerAdapter( /* context */ this, - personalAdapter, - workAdapter, + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter), + new TabConfig<>( + PROFILE_WORK, + mDevicePolicyResources.getWorkTabLabel(), + mDevicePolicyResources.getWorkTabAccessibilityLabel(), + TAB_TAG_WORK, + workAdapter)), createEmptyStateProvider(workProfileUserHandle), - () -> mWorkProfileAvailability.isQuietModeEnabled(), + /* Supplier<Boolean> (QuietMode enabled) == !(available) */ + () -> !(mProfiles.getWorkProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getWorkProfile()))), selectedProfile, workProfileUserHandle, - getAnnotatedUserHandles().cloneProfileUserHandle); + mProfiles.getCloneHandle()); } /** * Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. - * @throws IllegalArgumentException if the value passed to the {@link #EXTRA_SELECTED_PROFILE} - * extra is not {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} */ final int getSelectedProfileExtra() { - int selectedProfile = -1; - if (getIntent().hasExtra(EXTRA_SELECTED_PROFILE)) { - selectedProfile = getIntent().getIntExtra(EXTRA_SELECTED_PROFILE, /* defValue = */ -1); - if (selectedProfile != PROFILE_PERSONAL && selectedProfile != PROFILE_WORK) { - throw new IllegalArgumentException(EXTRA_SELECTED_PROFILE + " has invalid value " - + selectedProfile + ". Must be either ResolverActivity.PROFILE_PERSONAL or " - + "ResolverActivity.PROFILE_WORK."); - } + Profile.Type selected = mRequest.getSelectedProfile(); + if (selected == null) { + return -1; + } + switch (selected) { + case PERSONAL: return PROFILE_PERSONAL; + case WORK: return PROFILE_WORK; + default: return -1; } - return selectedProfile; } - protected final @Profile int getCurrentProfile() { - UserHandle launchUser = getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; - UserHandle personalUser = getAnnotatedUserHandles().personalProfileUserHandle; + protected final @ProfileType int getCurrentProfile() { + UserHandle launchUser = mProfiles.getTabOwnerUserHandleForLaunch(); + UserHandle personalUser = mProfiles.getPersonalHandle(); return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; } - protected final AnnotatedUserHandles getAnnotatedUserHandles() { - return mLazyAnnotatedUserHandles.get(); - } - - private boolean hasWorkProfile() { - return getAnnotatedUserHandles().workProfileUserHandle != null; - } - - private boolean hasCloneProfile() { - return getAnnotatedUserHandles().cloneProfileUserHandle != null; - } - - protected final boolean isLaunchedAsCloneProfile() { - UserHandle launchUser = getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - UserHandle cloneUser = getAnnotatedUserHandles().cloneProfileUserHandle; - return hasCloneProfile() && launchUser.equals(cloneUser); - } - - protected final boolean shouldShowTabs() { - return hasWorkProfile(); - } - - protected final void onProfileClick(View v) { - final DisplayResolveInfo dri = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); - if (dri == null) { - return; - } - - // Do not show the profile switch message anymore. - mProfileSwitchMessage = null; - - onTargetSelected(dri, false); - finish(); - } - private void updateIntentPickerPaddings() { View titleCont = findViewById(com.android.internal.R.id.title_container); titleCont.setPadding( @@ -1358,14 +1086,15 @@ public class ResolverActivity extends FragmentActivity implements } private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + // TODO: Test isolation bug, referencing getUser() will break tests with faked profiles + if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { return; } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) .setBoolean( currentUserHandle.equals( - getAnnotatedUserHandles().personalProfileUserHandle)) + mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); @@ -1399,66 +1128,6 @@ public class ResolverActivity extends FragmentActivity implements return new Option(getOrLoadDisplayLabel(target), index); } - public final Intent getTargetIntent() { - return mIntents.isEmpty() ? null : mIntents.get(0); - } - - protected final String getReferrerPackageName() { - final Uri referrer = getReferrer(); - if (referrer != null && "android-app".equals(referrer.getScheme())) { - return referrer.getHost(); - } - return null; - } - - @Override // ResolverListCommunicator - public final void updateProfileViewButton() { - if (mProfileView == null) { - return; - } - - final DisplayResolveInfo dri = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); - if (dri != null && !shouldShowTabs()) { - mProfileView.setVisibility(View.VISIBLE); - View text = mProfileView.findViewById(com.android.internal.R.id.profile_button); - if (!(text instanceof TextView)) { - text = mProfileView.findViewById(com.android.internal.R.id.text1); - } - ((TextView) text).setText(dri.getDisplayLabel()); - } else { - mProfileView.setVisibility(View.GONE); - } - } - - private void setProfileSwitchMessage(int contentUserHint) { - if ((contentUserHint != UserHandle.USER_CURRENT) - && (contentUserHint != UserHandle.myUserId())) { - UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - UserInfo originUserInfo = userManager.getUserInfo(contentUserHint); - boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile() - : false; - boolean targetIsManaged = userManager.isManagedProfile(); - if (originIsManaged && !targetIsManaged) { - mProfileSwitchMessage = getForwardToPersonalMsg(); - } else if (!originIsManaged && targetIsManaged) { - mProfileSwitchMessage = getForwardToWorkMsg(); - } - } - } - - private String getForwardToPersonalMsg() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - FORWARD_INTENT_TO_PERSONAL, - () -> getString(R.string.forward_intent_to_owner)); - } - - private String getForwardToWorkMsg() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - FORWARD_INTENT_TO_WORK, - () -> getString(R.string.forward_intent_to_work)); - } - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { final ActionTitle title = mResolvingHome ? ActionTitle.HOME @@ -1481,73 +1150,6 @@ public class ResolverActivity extends FragmentActivity implements } } - final void dismiss() { - if (!isFinishing()) { - finish(); - } - } - - @Override - protected final void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register( - this, - getMainLooper(), - getAnnotatedUserHandles().personalProfileUserHandle, - false); - if (shouldShowTabs()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register( - this, - getMainLooper(), - getAnnotatedUserHandles().workProfileUserHandle, - false); - } - mRegistered = true; - } - if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { - if (mWorkProfileAvailability.isQuietModeEnabled()) { - mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived(); - } - } - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - updateProfileViewButton(); - } - - @Override - protected final void onStart() { - super.onStart(); - - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (shouldShowTabs()) { - mWorkProfileAvailability.registerWorkProfileStateReceiver(this); - } - } - - @Override - protected final void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - - @Override - protected final void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); - } - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - private boolean hasManagedProfile() { UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); if (userManager == null) { @@ -1569,7 +1171,7 @@ public class ResolverActivity extends FragmentActivity implements private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { try { - ApplicationInfo appInfo = getPackageManager().getApplicationInfo( + ApplicationInfo appInfo = mPackageManager.getApplicationInfo( resolveInfo.activityInfo.packageName, 0 /* default flags */); return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; } catch (NameNotFoundException e) { @@ -1587,7 +1189,8 @@ public class ResolverActivity extends FragmentActivity implements // In case of clonedProfile being active, we do not allow the 'Always' option in the // disambiguation dialog of Personal Profile as the package manager cannot distinguish // between cross-profile preferred activities. - if (hasCloneProfile() && (mMultiProfilePagerAdapter.getCurrentPage() == PROFILE_PERSONAL)) { + if (mProfiles.getCloneUserPresent() + && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) { mAlwaysButton.setEnabled(false); return; } @@ -1613,41 +1216,28 @@ public class ResolverActivity extends FragmentActivity implements if (ri != null) { ActivityInfo activityInfo = ri.activityInfo; - boolean hasRecordPermission = - mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO, + boolean hasRecordPermission = mPackageManager + .checkPermission(android.Manifest.permission.RECORD_AUDIO, activityInfo.packageName) - == android.content.pm.PackageManager.PERMISSION_GRANTED; + == PackageManager.PERMISSION_GRANTED; if (!hasRecordPermission) { // OK, we know the record permission, is this a capture device - boolean hasAudioCapture = - getIntent().getBooleanExtra( - ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + boolean hasAudioCapture = mViewModel.getRequest().getValue().isAudioCaptureDevice(); enabled = !hasAudioCapture; } } mAlwaysButton.setEnabled(enabled); } - private String getWorkProfileNotSupportedMsg(String launcherName) { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_PROFILE_NOT_SUPPORTED, - () -> getString( - R.string.activity_resolver_work_profiles_support, - launcherName), - launcherName); - } - @Override // ResolverListCommunicator public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, boolean rebuildCompleted) { if (isAutolaunching()) { return; } - if (mIsIntentPicker) { - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .setUseLayoutWithDefault(useLayoutWithDefault()); - } + mMultiProfilePagerAdapter.setUseLayoutWithDefault(useLayoutWithDefault()); + if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); } else { @@ -1696,45 +1286,6 @@ public class ResolverActivity extends FragmentActivity implements } } - @VisibleForTesting - protected void safelyStartActivityInternal( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // If the target is suspended, the activity will not be successfully launched. - // Do not unregister from package manager updates in this case - if (!cti.isSuspended() && mRegistered) { - if (mPersonalPackageMonitor != null) { - mPersonalPackageMonitor.unregister(); - } - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - // If needed, show that intent is forwarded - // from managed profile to owner or other way around. - if (mProfileSwitchMessage != null) { - Toast.makeText(this, mProfileSwitchMessage, Toast.LENGTH_LONG).show(); - } - if (!mSafeForwardingMode) { - if (cti.startAsUser(this, options, user)) { - onActivityStarted(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - return; - } - try { - if (cti.startAsCaller(this, options, user.getIdentifier())) { - onActivityStarted(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - } catch (RuntimeException e) { - Slog.wtf(TAG, - "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp - + " package " + getLaunchedFromPackage() + ", while running in " - + ActivityThread.currentProcessName(), e); - } - } - final void showTargetDetails(ResolveInfo ri) { Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) @@ -1754,13 +1305,9 @@ public class ResolverActivity extends FragmentActivity implements Trace.beginSection("configureContentView"); // We partially rebuild the inactive adapter to determine if we should auto launch // isTabLoaded will be true here if the empty state screen is shown instead of the list. - boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildActiveTab(true) - || mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded(); - if (shouldShowTabs()) { - boolean rebuildInactiveCompleted = mMultiProfilePagerAdapter.rebuildInactiveTab(false) - || mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded(); - rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; - } + // To date, we really only care about "partially rebuilding" tabs for work and/or personal. + boolean rebuildCompleted = + mMultiProfilePagerAdapter.rebuildTabs(mProfiles.getWorkProfilePresent()); if (shouldUseMiniResolver()) { configureMiniResolverContent(targetDataLoader); @@ -1774,7 +1321,8 @@ public class ResolverActivity extends FragmentActivity implements mLayoutId = getLayoutResource(); } setContentView(mLayoutId); - mMultiProfilePagerAdapter.setupViewPager(findViewById(com.android.internal.R.id.profile_pager)); + mMultiProfilePagerAdapter.setupViewPager( + findViewById(com.android.internal.R.id.profile_pager)); boolean result = postRebuildList(rebuildCompleted); Trace.endSection(); return result; @@ -1790,18 +1338,26 @@ public class ResolverActivity extends FragmentActivity implements mLayoutId = R.layout.miniresolver; setContentView(mLayoutId); - DisplayResolveInfo sameProfileResolveInfo = - mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo(); boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; - final ResolverListAdapter inactiveAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); + ResolverListAdapter sameProfileAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + DisplayResolveInfo sameProfileResolveInfo = sameProfileAdapter.getFirstDisplayResolveInfo(); + final DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.getFirstDisplayResolveInfo(); // Load the icon asynchronously ImageView icon = findViewById(com.android.internal.R.id.icon); - targetDataLoader.loadAppTargetIcon( + targetDataLoader.getOrLoadAppTargetIcon( otherProfileResolveInfo, inactiveAdapter.getUserHandle(), (drawable) -> { @@ -1834,6 +1390,69 @@ public class ResolverActivity extends FragmentActivity implements }); } + private boolean isTwoPagePersonalAndWorkConfiguration() { + return (mMultiProfilePagerAdapter.getCount() == 2) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); + } + + @VisibleForTesting + protected void safelyStartActivityInternal( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // If the target is suspended, the activity will not be successfully launched. + // Do not unregister from package manager updates in this case + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + String profileSwitchMessage = + mIntentForwarding.forwardMessageFor(mRequest.getIntent()); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + + mViewModel.getActivityModel().getLaunchedFromUid() + + " package " + mViewModel.getActivityModel().getLaunchedFromPackage() + + ", while running in " + ActivityThread.currentProcessName(), e); + } + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; + } + + setupViewVisibilities(); + + if (mProfiles.getWorkProfilePresent()) { + setupProfileTabs(); + } + + return false; + } + /** * Mini resolver should be used when all of the following are true: * 1. This is the intent picker (ResolverActivity). @@ -1841,17 +1460,19 @@ public class ResolverActivity extends FragmentActivity implements * 3. The other profile has a single non-browser match. */ private boolean shouldUseMiniResolver() { - if (!mIsIntentPicker) { - return false; - } - if (mMultiProfilePagerAdapter.getActiveListAdapter() == null - || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } + ResolverListAdapter sameProfileAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + ResolverListAdapter otherProfileAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { Log.d(TAG, "No targets in the current profile"); @@ -1876,53 +1497,6 @@ public class ResolverActivity extends FragmentActivity implements return true; } - /** - * Finishing procedures to be performed after the list has been rebuilt. - * @param rebuildCompleted - * @return <code>true</code> if the activity is finishing and creation should halt. - */ - final boolean postRebuildListInternal(boolean rebuildCompleted) { - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - - // We only rebuild asynchronously when we have multiple elements to sort. In the case where - // we're already done, we can check if we should auto-launch immediately. - if (rebuildCompleted && maybeAutolaunchActivity()) { - return true; - } - - setupViewVisibilities(); - - if (shouldShowTabs()) { - setupProfileTabs(); - } - - return false; - } - - private int isPermissionGranted(String permission, int uid) { - return ActivityManager.checkComponentPermission(permission, uid, - /* owningUid= */-1, /* exported= */ true); - } - - /** - * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} - */ - private boolean maybeAutolaunchActivity() { - int numberOfProfiles = mMultiProfilePagerAdapter.getItemCount(); - if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { - return true; - } else if (numberOfProfiles == 2 - && mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded() - && mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded() - && maybeAutolaunchIfCrossProfileSupported()) { - // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the - // correct intent-picker UIs (e.g., mini-resolver) if it was launched without - // ACTION_SEND. - return true; - } - return false; - } - private boolean maybeAutolaunchIfSingleTarget() { int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); if (count != 1) { @@ -1945,42 +1519,57 @@ public class ResolverActivity extends FragmentActivity implements } /** - * When we have a personal and a work profile, we auto launch in the following scenario: + * When we have just a personal and a work profile, we auto launch in the following scenario: * - There is 1 resolved target on each profile * - That target is the same app on both profiles * - The target app has permission to communicate cross profiles * - The target app has declared it supports cross-profile communication via manifest metadata */ private boolean maybeAutolaunchIfCrossProfileSupported() { - ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); - int count = activeListAdapter.getUnfilteredCount(); - if (count != 1) { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } + + ResolverListAdapter activeListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + ResolverListAdapter inactiveListAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); - if (inactiveListAdapter.getUnfilteredCount() != 1) { + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { return false; } - TargetInfo activeProfileTarget = activeListAdapter - .targetInfoForPosition(0, false); + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } + + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals(activeProfileTarget.getResolvedComponentName(), + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), inactiveProfileTarget.getResolvedComponentName())) { return false; } + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { return false; } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!canAppInteractCrossProfiles(packageName)) { + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { return false; } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) .setBoolean(activeListAdapter.getUserHandle() - .equals(getAnnotatedUserHandles().personalProfileUserHandle)) + .equals(mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); @@ -1988,140 +1577,66 @@ public class ResolverActivity extends FragmentActivity implements return true; } + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + /** - * Returns whether the package has the necessary permissions to interact across profiles on - * behalf of a given user. - * - * <p>This means meeting the following condition: - * <ul> - * <li>The app's {@link ApplicationInfo#crossProfile} flag must be true, and at least - * one of the following conditions must be fulfilled</li> - * <li>{@code Manifest.permission.INTERACT_ACROSS_USERS_FULL} granted.</li> - * <li>{@code Manifest.permission.INTERACT_ACROSS_USERS} granted.</li> - * <li>{@code Manifest.permission.INTERACT_ACROSS_PROFILES} granted, or the corresponding - * AppOps {@code android:interact_across_profiles} is set to "allow".</li> - * </ul> - * + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} */ - private boolean canAppInteractCrossProfiles(String packageName) { - ApplicationInfo applicationInfo; - try { - applicationInfo = getPackageManager().getApplicationInfo(packageName, 0); - } catch (NameNotFoundException e) { - Log.e(TAG, "Package " + packageName + " does not exist on current user."); - return false; - } - if (!applicationInfo.crossProfile) { + private boolean maybeAutolaunchActivity() { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } - int packageUid = applicationInfo.uid; + ResolverListAdapter activeListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, - packageUid) == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS, packageUid) - == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, - PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED) { - return true; - } - return false; - } + ResolverListAdapter inactiveListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); - private boolean isAutolaunching() { - return !mRegistered && isFinishing(); - } + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; + } - private void setupProfileTabs() { - maybeHideDivider(); - TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - tabHost.setup(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSaveEnabled(false); - - Button personalButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - personalButton.setText(getPersonalTabLabel()); - personalButton.setContentDescription(getPersonalTabAccessibilityLabel()); - - TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(personalButton); - tabHost.addTab(tabSpec); - - Button workButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - workButton.setText(getWorkTabLabel()); - workButton.setContentDescription(getWorkTabAccessibilityLabel()); - - tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(workButton); - tabHost.addTab(tabSpec); - - TabWidget tabWidget = tabHost.getTabWidget(); - tabWidget.setVisibility(View.VISIBLE); - updateActiveTabStyle(tabHost); - - tabHost.setOnTabChangedListener(tabId -> { - updateActiveTabStyle(tabHost); - if (TAB_TAG_PERSONAL.equals(tabId)) { - viewPager.setCurrentItem(0); - } else { - viewPager.setCurrentItem(1); - } - setupViewVisibilities(); - maybeLogProfileChange(); - onProfileTabSelected(); - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(viewPager.getCurrentItem()) - .setStrings(getMetricsCategory()) - .write(); - }); + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } - viewPager.setVisibility(View.VISIBLE); - tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); - mMultiProfilePagerAdapter.setOnProfileSelectedListener( - new MultiProfilePagerAdapter.OnProfileSelectedListener() { - @Override - public void onProfileSelected(int index) { - tabHost.setCurrentTab(index); - resetButtonBar(); - resetCheckedItem(); - } + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } - @Override - public void onProfilePageStateChanged(int state) { - onHorizontalSwipeStateChanged(state); - } - }); - mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = tabHost.getTabWidget().getChildAt(1); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }; - } + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } - private String getPersonalTabLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_PERSONAL_TAB, () -> getString(R.string.resolver_personal_tab)); - } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { + return false; + } - private String getWorkTabLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab)); + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(mProfiles.getPersonalHandle())) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; } private void maybeHideDivider() { - if (!mIsIntentPicker) { - return; - } final View divider = findViewById(com.android.internal.R.id.divider); if (divider == null) { return; @@ -2130,41 +1645,9 @@ public class ResolverActivity extends FragmentActivity implements } private void resetCheckedItem() { - if (!mIsIntentPicker) { - return; - } mLastSelected = ListView.INVALID_POSITION; - ListView inactiveListView = (ListView) mMultiProfilePagerAdapter.getInactiveAdapterView(); - if (inactiveListView.getCheckedItemCount() > 0) { - inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); - } - } - - private String getPersonalTabAccessibilityLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_PERSONAL_TAB_ACCESSIBILITY, - () -> getString(R.string.resolver_personal_tab_accessibility)); - } - - private String getWorkTabAccessibilityLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_TAB_ACCESSIBILITY, - () -> getString(R.string.resolver_work_tab_accessibility)); - } - - private static int getAttrColor(Context context, int attr) { - TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - int colorAccent = ta.getColor(0, 0); - ta.recycle(); - return colorAccent; - } - - private void updateActiveTabStyle(TabHost tabHost) { - int currentTab = tabHost.getCurrentTab(); - TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); - TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); - selected.setSelected(true); - unselected.setSelected(false); + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .clearCheckedItemsInInactiveProfiles(); } private void setupViewVisibilities() { @@ -2192,10 +1675,7 @@ public class ResolverActivity extends FragmentActivity implements private void setupAdapterListView(ListView listView, ItemClickListener listener) { listView.setOnItemClickListener(listener); listView.setOnItemLongClickListener(listener); - - if (mSupportsAlwaysUseOption) { - listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); - } + listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); } /** @@ -2206,17 +1686,17 @@ public class ResolverActivity extends FragmentActivity implements && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { return; } - if (!shouldShowTabs() + if (!mProfiles.getWorkProfilePresent() && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { final TextView titleView = findViewById(com.android.internal.R.id.title); if (titleView != null) { titleView.setVisibility(View.GONE); } } - - CharSequence title = mTitle != null - ? mTitle - : getTitleForAction(getTargetIntent(), mDefaultTitleResId); + ResolverRequest request = mViewModel.getRequest().getValue(); + CharSequence title = mViewModel.getRequest().getValue().getTitle() != null + ? request.getTitle() + : getTitleForAction(request.getIntent(), 0); if (!TextUtils.isEmpty(title)) { final TextView titleView = findViewById(com.android.internal.R.id.title); @@ -2261,25 +1741,9 @@ public class ResolverActivity extends FragmentActivity implements public final boolean useLayoutWithDefault() { // We only use the default app layout when the profile of the active user has a // filtered item. We always show the same default app even in the inactive user profile. - boolean adapterForCurrentUserHasFilteredItem = - mMultiProfilePagerAdapter.getListAdapterForUserHandle( - getAnnotatedUserHandles().tabOwnerUserHandleForLaunch).hasFilteredItem(); - return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem; - } - - /** - * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets - * called and we are launched in a new task. - */ - protected final void setRetainInOnStop(boolean retainInOnStop) { - mRetainInOnStop = retainInOnStop; - } - - private boolean inactiveListAdapterHasItems() { - if (!shouldShowTabs()) { - return false; - } - return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; + return mMultiProfilePagerAdapter.getListAdapterForUserHandle( + mProfiles.getTabOwnerUserHandleForLaunch() + ).hasFilteredItem(); } final class ItemClickListener implements AdapterView.OnItemClickListener, @@ -2336,11 +1800,37 @@ public class ResolverActivity extends FragmentActivity implements } - /** 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; + private void setupProfileTabs() { + maybeHideDivider(); + + TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + + mMultiProfilePagerAdapter.setupProfileTabs( + getLayoutInflater(), + tabHost, + viewPager, + R.layout.resolver_profile_tab_button, + com.android.internal.R.id.profile_pager, + () -> onProfileTabSelected(viewPager.getCurrentItem()), + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { + resetButtonBar(); + resetCheckedItem(); + } + + @Override + public void onProfilePageStateChanged(int state) {} + }); + mOnSwitchOnWorkSelectedListener = () -> { + final View workTab = + tabHost.getTabWidget().getChildAt( + mMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; } static final class PickTargetOptionRequest extends PickOptionRequest { @@ -2384,7 +1874,7 @@ public class ResolverActivity extends FragmentActivity implements * {@link ResolverListController} configured for the provided {@code userHandle}. */ protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return getAnnotatedUserHandles().getQueryIntentsUser(userHandle); + return mProfiles.getQueryIntentsHandle(userHandle); } /** @@ -2404,9 +1894,9 @@ public class ResolverActivity extends FragmentActivity implements // Add clonedProfileUserHandle to the list only if we are: // a. Building the Personal Tab. // b. CloneProfile exists on the device. - if (userHandle.equals(getAnnotatedUserHandles().personalProfileUserHandle) - && hasCloneProfile()) { - userList.add(getAnnotatedUserHandles().cloneProfileUserHandle); + if (userHandle.equals(mProfiles.getPersonalHandle()) + && mProfiles.getCloneUserPresent()) { + userList.add(mProfiles.getCloneHandle()); } return userList; } diff --git a/java/src/com/android/intentresolver/ResolverHelper.kt b/java/src/com/android/intentresolver/ResolverHelper.kt new file mode 100644 index 00000000..d12ba7d5 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverHelper.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.app.Activity +import android.os.UserHandle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.Background +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.ui.viewmodel.ResolverViewModel +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.log +import dagger.hilt.android.scopes.ActivityScoped +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher + +private const val TAG: String = "ResolverHelper" + +/** + * __Purpose__ + * + * Cleanup aid. Provides a pathway to cleaner code. + * + * __Incoming References__ + * + * ResolverHelper must not expose any properties or functions directly back to ResolverActivity. If + * a value or operation is required by ResolverActivity, then it must be added to + * ResolverInitializer (or a new interface as appropriate) with ResolverActivity supplying a + * callback to receive it at the appropriate point. This enforces unidirectional control flow. + * + * __Outgoing References__ + * + * _ResolverActivity_ + * + * This class must only reference it's host as Activity/ComponentActivity; no down-cast to + * [ResolverActivity]. Other components should be created here or supplied via Injection, and not + * referenced directly from the activity. This prevents circular dependencies from forming. If + * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described + * above in 'Incoming References', see [ResolverInitializer]. + * + * _Elsewhere_ + * + * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of + * referenced from an existing location. If not available for injection, the value should be + * constructed here, then provided to where it is needed. + */ +@ActivityScoped +@JavaInterop +class ResolverHelper +@Inject +constructor( + hostActivity: Activity, + private val userInteractor: UserInteractor, + @Background private val background: CoroutineDispatcher, +) : DefaultLifecycleObserver { + // This is guaranteed by Hilt, since only a ComponentActivity is injectable. + private val activity: ComponentActivity = hostActivity as ComponentActivity + private val viewModel by activity.viewModels<ResolverViewModel>() + + private lateinit var activityInitializer: Runnable + + init { + activity.lifecycle.addObserver(this) + } + + /** + * Set the initialization hook for the host activity. + * + * This _must_ be called from [ResolverActivity.onCreate]. + */ + fun setInitializer(initializer: Runnable) { + if (activity.lifecycle.currentState != Lifecycle.State.INITIALIZED) { + error("setInitializer must be called before onCreate returns") + } + activityInitializer = initializer + } + + /** Invoked by Lifecycle, after Activity.onCreate() _returns_. */ + override fun onCreate(owner: LifecycleOwner) { + Log.i(TAG, "CREATE") + Log.i(TAG, "${viewModel.activityModel}") + + val callerUid: Int = viewModel.activityModel.launchedFromUid + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a resolver from uid $callerUid") + activity.finish() + return + } + + when (val request = viewModel.initialRequest) { + is Valid -> initializeActivity(request) + is Invalid -> reportErrorsAndFinish(request) + } + } + + private fun reportErrorsAndFinish(request: Invalid<ResolverRequest>) { + request.errors.forEach { it.log(TAG) } + activity.finish() + } + + private fun initializeActivity(request: Valid<ResolverRequest>) { + Log.d(TAG, "initializeActivity") + request.warnings.forEach { it.log(TAG) } + + activityInitializer.run() + } +} diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 80d07d2c..5fd37d43 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -448,6 +448,9 @@ public class ResolverListAdapter extends BaseAdapter { // Send an "incomplete" list-ready while the async task is running. postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ false); mBgExecutor.execute(() -> { + if (isDestroyed()) { + return; + } List<ResolvedComponentInfo> sortedComponents = null; //TODO: the try-catch logic here is to formally match the AsyncTask's behavior. // Empirically, we don't need it as in the case on an exception, the app will crash and @@ -736,26 +739,31 @@ public class ResolverListAdapter extends BaseAdapter { holder.bindLabel("", ""); loadLabel(dri); } - holder.bindIcon(info); if (!dri.hasDisplayIcon()) { loadIcon(dri); } + holder.bindIcon(info); } } protected final void loadIcon(DisplayResolveInfo info) { if (mRequestedIcons.add(info)) { - mTargetDataLoader.loadAppTargetIcon( + Drawable icon = mTargetDataLoader.getOrLoadAppTargetIcon( info, getUserHandle(), - (drawable) -> onIconLoaded(info, drawable)); + (drawable) -> { + onIconLoaded(info, drawable); + notifyDataSetChanged(); + }); + if (icon != null) { + onIconLoaded(info, icon); + } } } private void onIconLoaded(DisplayResolveInfo displayResolveInfo, Drawable drawable) { if (!displayResolveInfo.hasDisplayIcon()) { displayResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); - notifyDataSetChanged(); } } @@ -785,6 +793,10 @@ public class ResolverListAdapter extends BaseAdapter { mRequestedLabels.clear(); } + public final boolean isDestroyed() { + return mDestroyed.get(); + } + private static ColorMatrixColorFilter getSuspendedColorMatrix() { if (sSuspendedMatrixColorFilter == null) { @@ -815,7 +827,7 @@ public class ResolverListAdapter extends BaseAdapter { public void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { final DisplayResolveInfo iconInfo = getFilteredItem(); if (iconInfo != null) { - mTargetDataLoader.loadAppTargetIcon( + mTargetDataLoader.getOrLoadAppTargetIcon( iconInfo, getUserHandle(), iconView::setImageDrawable); } } @@ -833,7 +845,7 @@ public class ResolverListAdapter extends BaseAdapter { userHandle); } - public final List<Intent> getIntents() { + public List<Intent> getIntents() { // TODO: immutable copy? return mIntents; } diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java deleted file mode 100644 index 591c23b7..00000000 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ /dev/null @@ -1,120 +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.content.Context; -import android.os.UserHandle; -import android.view.LayoutInflater; -import android.view.ViewGroup; -import android.widget.ListView; - -import androidx.viewpager.widget.PagerAdapter; - -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.internal.annotations.VisibleForTesting; - -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 - MultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> { - private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - - public ResolverMultiProfilePagerAdapter( - Context context, - ResolverListAdapter adapter, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - this( - context, - ImmutableList.of(adapter), - emptyStateProvider, - workProfileQuietModeChecker, - /* defaultProfile= */ 0, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier()); - } - - public ResolverMultiProfilePagerAdapter(Context context, - ResolverListAdapter personalAdapter, - ResolverListAdapter workAdapter, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - this( - context, - ImmutableList.of(personalAdapter, workAdapter), - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier()); - } - - private ResolverMultiProfilePagerAdapter( - Context context, - ImmutableList<ResolverListAdapter> listAdapters, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { - super( - listAdapter -> listAdapter, - (listView, bindAdapter) -> listView.setAdapter(bindAdapter), - listAdapters, - emptyStateProvider, - workProfileQuietModeChecker, - defaultProfile, - workProfileUserHandle, - cloneProfileUserHandle, - () -> (ViewGroup) LayoutInflater.from(context).inflate( - R.layout.resolver_list_per_profile, null, false), - bottomPaddingOverrideSupplier); - mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; - } - - public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); - } - - private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { - private boolean mUseLayoutWithDefault; - - public void setUseLayoutWithDefault(boolean useLayoutWithDefault) { - mUseLayoutWithDefault = useLayoutWithDefault; - } - - @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 0496579d..891ace87 100644 --- a/java/src/com/android/intentresolver/ResolverViewPager.java +++ b/java/src/com/android/intentresolver/ResolverViewPager.java @@ -75,6 +75,12 @@ public class ResolverViewPager extends ViewPager { @Override public boolean onInterceptTouchEvent(MotionEvent ev) { - return !isLayoutRtl() && mSwipingEnabled && super.onInterceptTouchEvent(ev); + return !isEnabled() + || (!isLayoutRtl() && mSwipingEnabled && super.onInterceptTouchEvent(ev)); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + return isEnabled() && super.onTouchEvent(ev); } } diff --git a/java/src/com/android/intentresolver/SecureSettings.kt b/java/src/com/android/intentresolver/SecureSettings.kt index a4853fd8..1e938895 100644 --- a/java/src/com/android/intentresolver/SecureSettings.kt +++ b/java/src/com/android/intentresolver/SecureSettings.kt @@ -19,9 +19,7 @@ package com.android.intentresolver import android.content.ContentResolver import android.provider.Settings -/** - * A proxy class for secure settings, for easier testing. - */ +/** A proxy class for secure settings, for easier testing. */ open class SecureSettings { open fun getString(resolver: ContentResolver, name: String): String? { return Settings.Secure.getString(resolver, name) diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index efaaf894..2d5ec451 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -30,13 +30,19 @@ import androidx.annotation.Nullable; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.ui.AppShortcutLimit; +import com.android.intentresolver.ui.EnforceShortcutLimit; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; -class ShortcutSelectionLogic { +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public 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; @@ -49,9 +55,10 @@ class ShortcutSelectionLogic { private final Comparator<ChooserTarget> mBaseTargetComparator = (lhs, rhs) -> Float.compare(rhs.getScore(), lhs.getScore()); - ShortcutSelectionLogic( - int maxShortcutTargetsPerApp, - boolean applySharingAppLimits) { + @Inject + public ShortcutSelectionLogic( + @AppShortcutLimit int maxShortcutTargetsPerApp, + @EnforceShortcutLimit boolean applySharingAppLimits) { mMaxShortcutTargetsPerApp = maxShortcutTargetsPerApp; mApplySharingAppLimits = applySharingAppLimits; } @@ -78,7 +85,7 @@ class ShortcutSelectionLogic { + targets.size() + " targets"); } - if (targets.size() == 0) { + if (targets.isEmpty()) { return false; } Collections.sort(targets, mBaseTargetComparator); diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java index 750b24ac..f4871e36 100644 --- a/java/src/com/android/intentresolver/SimpleIconFactory.java +++ b/java/src/com/android/intentresolver/SimpleIconFactory.java @@ -58,7 +58,6 @@ import org.xmlpull.v1.XmlPullParser; import java.nio.ByteBuffer; import java.util.Optional; - /** * @deprecated Use the Launcher3 Iconloaderlib at packages/apps/Launcher3/iconloaderlib. This class * is a temporary fork of Iconloader. It combines all necessary methods to render app icons that are diff --git a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/annotation/JavaInterop.kt index 15c5018a..e268af98 100644 --- a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt +++ b/java/src/com/android/intentresolver/annotation/JavaInterop.kt @@ -14,13 +14,15 @@ * limitations under the License. */ -package com.android.intentresolver.v2.annotation +package com.android.intentresolver.annotation /** * Apply to code which exists specifically to easy integration with existing Java and Java APIs. * * The goal is to prevent usage from Kotlin when a more idiomatic alternative is available. */ -@RequiresOptIn("This is a a property, function or class specifically supporting Java " + - "interoperability. Usage from Kotlin should be limited to interactions with Java.") +@RequiresOptIn( + "This is a a property, function or class specifically supporting Java " + + "interoperability. Usage from Kotlin should be limited to interactions with Java." +) annotation class JavaInterop diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 536f11ce..5e44c53e 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -196,6 +196,7 @@ public class DisplayResolveInfo implements TargetInfo { } @Override + @NonNull public ComponentName getResolvedComponentName() { return new ComponentName(mResolveInfo.activityInfo.packageName, mResolveInfo.activityInfo.name); diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java new file mode 100644 index 00000000..3462b726 --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.chooser; + + +import android.content.Context; + +import java.text.Collator; +import java.util.Comparator; + +/** + * Sort intents alphabetically based on display label. + */ +public class DisplayResolveInfoAzInfoComparator implements Comparator<DisplayResolveInfo> { + Comparator<DisplayResolveInfo> mComparator; + public DisplayResolveInfoAzInfoComparator(Context context) { + Collator collator = Collator + .getInstance(context.getResources().getConfiguration().locale); + // Adding two stage comparator, first stage compares using displayLabel, next stage + // compares using resolveInfo.userHandle + mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) + .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier()); + } + + @Override + public int compare( + DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { + return mComparator.compare(lhsp, rhsp); + } +} diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index 4fe28384..95cb443e 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -23,6 +23,7 @@ import android.os.Bundle; import android.os.UserHandle; import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; @@ -123,6 +124,7 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { } @Override + @NonNull public ComponentName getResolvedComponentName() { if (hasSelected()) { return mTargetInfos.get(mSelected).getResolvedComponentName(); diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 21c909ea..dc36e584 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -25,14 +25,11 @@ import androidx.lifecycle.ViewModel abstract class BasePreviewViewModel : ViewModel() { @get:MainThread abstract val previewDataProvider: PreviewDataProvider @get:MainThread abstract val imageLoader: ImageLoader - abstract val payloadToggleInteractor: PayloadToggleInteractor? @MainThread abstract fun init( targetIntent: Intent, - chooserIntent: Intent, additionalContentUri: Uri?, - focusedItemIdx: Int, isPayloadTogglingEnabled: Boolean, ) } diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt new file mode 100644 index 00000000..ce064cdf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.core.util.lruCache +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import java.util.function.Consumer +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewMaxConcurrency + +/** + * Implementation of [ImageLoader]. + * + * Allows for cached or uncached loading of images and limits the number of concurrent requests. + * Requests are automatically cancelled when they are evicted from the cache. If image loading fails + * or the request is cancelled (e.g. by eviction), the returned [Bitmap] will be null. + */ +class CachingImagePreviewImageLoader +@Inject +constructor( + @ViewModelOwned private val scope: CoroutineScope, + @Background private val bgDispatcher: CoroutineDispatcher, + private val thumbnailLoader: ThumbnailLoader, + @PreviewCacheSize cacheSize: Int, + @PreviewMaxConcurrency maxConcurrency: Int, +) : ImageLoader { + + private val semaphore = Semaphore(maxConcurrency) + + private val cache = + lruCache( + maxSize = cacheSize, + create = { uri: Uri -> scope.async { loadUncachedImage(uri) } }, + onEntryRemoved = { evicted: Boolean, _, oldValue: Deferred<Bitmap?>, _ -> + // If removed due to eviction, cancel the coroutine, otherwise it is the + // responsibility + // of the caller of [cache.remove] to cancel the removed entry when done with it. + if (evicted) { + oldValue.cancel() + } + } + ) + + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { + callerScope.launch { callback.accept(loadCachedImage(uri)) } + } + + override fun prePopulate(uris: List<Uri>) { + uris.take(cache.maxSize()).map { cache[it] } + } + + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? { + return if (caching) { + loadCachedImage(uri) + } else { + loadUncachedImage(uri) + } + } + + private suspend fun loadUncachedImage(uri: Uri): Bitmap? = + withContext(bgDispatcher) { + runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } } + .onFailure { + ensureActive() + Log.d(TAG, "Failed to load preview for $uri", it) + } + .getOrNull() + } + + private suspend fun loadCachedImage(uri: Uri): Bitmap? = + // [Deferred#await] is called in a [runCatching] block to catch + // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope. + runCatching { cache[uri].await() }.getOrNull() + + companion object { + private const val TAG = "CachingImgPrevLoader" + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 6f201ad5..4b955c49 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -37,10 +37,11 @@ import com.android.intentresolver.ContentTypeHint; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; +import kotlinx.coroutines.CoroutineScope; + import java.util.List; import java.util.function.Consumer; - -import kotlinx.coroutines.CoroutineScope; +import java.util.function.Supplier; /** * Collection of helpers for building the content preview UI displayed in @@ -77,7 +78,9 @@ public final class ChooserContentPreviewUi { * Provides a share modification action, if any. */ @Nullable - ActionRow.Action getModifyShareAction(); + default ActionRow.Action getModifyShareAction() { + return null; + } /** * <p> @@ -93,6 +96,8 @@ public final class ChooserContentPreviewUi { @VisibleForTesting final ContentPreviewUi mContentPreviewUi; + private final Supplier</*@Nullable*/ActionRow.Action> mModifyShareActionFactory; + private View mHeadlineParent; public ChooserContentPreviewUi( CoroutineScope scope, @@ -100,6 +105,7 @@ public final class ChooserContentPreviewUi { Intent targetIntent, ImageLoader imageLoader, ActionFactory actionFactory, + Supplier</*@Nullable*/ActionRow.Action> modifyShareActionFactory, TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator, ContentTypeHint contentTypeHint, @@ -108,6 +114,7 @@ public final class ChooserContentPreviewUi { boolean isPayloadTogglingEnabled) { mScope = scope; mIsPayloadTogglingEnabled = isPayloadTogglingEnabled; + mModifyShareActionFactory = modifyShareActionFactory; mContentPreviewUi = createContentPreview( previewData, targetIntent, @@ -162,7 +169,7 @@ public final class ChooserContentPreviewUi { if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && mIsPayloadTogglingEnabled) { transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO - return new ShareouselContentPreviewUi(actionFactory); + return new ShareouselContentPreviewUi(); } boolean isSingleImageShare = previewData.getUriCount() == 1 @@ -218,9 +225,20 @@ public final class ChooserContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { - return mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent); + ViewGroup layout = + mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent); + mHeadlineParent = headlineViewParent; + ContentPreviewUi.displayModifyShareAction(mHeadlineParent, mModifyShareActionFactory.get()); + return layout; + } + + /** + * Update Modify Share Action, if it is inflated. + */ + public void updateModifyShareAction() { + ContentPreviewUi.displayModifyShareAction(mHeadlineParent, mModifyShareActionFactory.get()); } private static TextContentPreviewUi createTextPreview( diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index b0fb278e..8eaf3568 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -48,7 +48,7 @@ public abstract class ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent); + View headlineViewParent); protected static void updateViewWithImage(ImageView imageView, Bitmap image) { if (image == null) { @@ -98,16 +98,19 @@ public abstract class ContentPreviewUi { } } - protected static void displayModifyShareAction( - View layout, ChooserContentPreviewUi.ActionFactory actionFactory) { - ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction(); - if (modifyShareAction != null && layout != null) { - TextView modifyShareView = layout.findViewById(R.id.reselection_action); - if (modifyShareView != null) { - modifyShareView.setText(modifyShareAction.getLabel()); - modifyShareView.setVisibility(View.VISIBLE); - modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run()); - } + static void displayModifyShareAction( + View layout, @Nullable ActionRow.Action modifyShareAction) { + TextView modifyShareView = + layout == null ? null : layout.findViewById(R.id.reselection_action); + if (modifyShareView == null) { + return; + } + if (modifyShareAction != null) { + modifyShareView.setText(modifyShareAction.getLabel()); + modifyShareView.setVisibility(View.VISIBLE); + modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run()); + } else { + modifyShareView.setVisibility(View.GONE); } } diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt deleted file mode 100644 index 6a12f56c..00000000 --- a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.ContentInterface -import android.content.Intent -import android.database.Cursor -import android.database.MatrixCursor -import android.net.Uri -import android.os.Bundle -import android.os.CancellationSignal -import android.service.chooser.AdditionalContentContract.Columns -import android.service.chooser.AdditionalContentContract.CursorExtraKeys -import android.util.Log -import android.util.SparseArray -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.coroutineScope - -private const val TAG = ContentPreviewUi.TAG - -/** - * A bi-directional cursor reader. Reads URI from the [cursor] starting from the given [startPos], - * filters items by [predicate]. - */ -class CursorUriReader( - private val cursor: Cursor, - startPos: Int, - private val pageSize: Int, - private val predicate: (Uri) -> Boolean, -) : PayloadToggleInteractor.CursorReader { - override val count = cursor.count - // Unread ranges are: - // - left: [0, leftPos); - // - right: [rightPos, count) - // i.e. read range is: [leftPos, rightPos) - private var rightPos = startPos.coerceIn(0, count) - private var leftPos = rightPos - - override val hasMoreBefore - get() = leftPos > 0 - - override val hasMoreAfter - get() = rightPos < count - - override fun readPageAfter(): SparseArray<Uri> { - if (!hasMoreAfter) return SparseArray() - if (!cursor.moveToPosition(rightPos)) { - rightPos = count - Log.w(TAG, "Failed to move the cursor to position $rightPos, stop reading the cursor") - return SparseArray() - } - val result = SparseArray<Uri>(pageSize) - do { - cursor - .getString(0) - ?.let(Uri::parse) - ?.takeIf { predicate(it) } - ?.let { uri -> result.append(rightPos, uri) } - rightPos++ - } while (result.size() < pageSize && cursor.moveToNext()) - maybeCloseCursor() - return result - } - - override fun readPageBefore(): SparseArray<Uri> { - if (!hasMoreBefore) return SparseArray() - val startPos = maxOf(0, leftPos - pageSize) - if (!cursor.moveToPosition(startPos)) { - leftPos = 0 - Log.w(TAG, "Failed to move the cursor to position $startPos, stop reading cursor") - return SparseArray() - } - val result = SparseArray<Uri>(leftPos - startPos) - for (pos in startPos until leftPos) { - cursor - .getString(0) - ?.let(Uri::parse) - ?.takeIf { predicate(it) } - ?.let { uri -> result.append(pos, uri) } - if (!cursor.moveToNext()) break - } - leftPos = startPos - maybeCloseCursor() - return result - } - - private fun maybeCloseCursor() { - if (!hasMoreBefore && !hasMoreAfter) { - close() - } - } - - override fun close() { - cursor.close() - } - - companion object { - suspend fun createCursorReader( - contentResolver: ContentInterface, - uri: Uri, - chooserIntent: Intent - ): CursorUriReader { - val cancellationSignal = CancellationSignal() - val cursor = - try { - coroutineScope { - runCatching { - contentResolver.query( - uri, - arrayOf(Columns.URI), - Bundle().apply { - putParcelable(Intent.EXTRA_INTENT, chooserIntent) - }, - cancellationSignal - ) - } - .getOrNull() - ?: MatrixCursor(arrayOf(Columns.URI)) - } - } catch (e: CancellationException) { - cancellationSignal.cancel() - throw e - } - return CursorUriReader( - cursor, - cursor.extras?.getInt(CursorExtraKeys.POSITION, 0) ?: 0, - 128, - ) { - it.authority != uri.authority - } - } - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index d4eea8b9..1749c6f7 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -76,23 +76,17 @@ class FileContentPreviewUi extends ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { - ViewGroup layout = displayInternal(resources, layoutInflater, parent, headlineViewParent); - displayModifyShareAction( - headlineViewParent == null ? layout : headlineViewParent, mActionFactory); - return layout; + View headlineViewParent) { + return displayInternal(resources, layoutInflater, parent, headlineViewParent); } private ViewGroup displayInternal( Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { mContentPreview = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); - if (headlineViewParent == null) { - headlineViewParent = mContentPreview; - } inflateHeadline(headlineViewParent); displayHeadline(headlineViewParent, mHeadlineGenerator.getFilesHeadline(mFileCount)); diff --git a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt index fe35365b..16a948df 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt +++ b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt @@ -22,8 +22,11 @@ class FileInfo private constructor(val uri: Uri, val previewUri: Uri?, val mimeT @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class Builder(val uri: Uri) { var previewUri: Uri? = null + @Synchronized get private set + var mimeType: String? = null + @Synchronized get private set @Synchronized fun withPreviewUri(uri: Uri?): Builder = apply { previewUri = uri } diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 6832c5c4..b50f5bc8 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -36,12 +36,12 @@ import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; +import kotlinx.coroutines.CoroutineScope; + import java.util.HashMap; import java.util.List; import java.util.function.Consumer; -import kotlinx.coroutines.CoroutineScope; - /** * FilesPlusTextContentPreviewUi is shown when the user is sending 1 or more files along with * non-empty EXTRA_TEXT. The text can be toggled with a checkbox. If a single image file is being @@ -108,11 +108,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { - ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); - displayModifyShareAction( - headlineViewParent == null ? layout : headlineViewParent, mActionFactory); - return layout; + View headlineViewParent) { + return displayInternal(layoutInflater, parent, headlineViewParent); } public void updatePreviewMetadata(List<FileInfo> files) { @@ -136,10 +133,10 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private ViewGroup displayInternal( LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_files_text, parent, false); - mHeadliveView = headlineViewParent == null ? mContentPreviewView : headlineViewParent; + mHeadliveView = headlineViewParent; inflateHeadline(mHeadliveView); final ActionRow actionRow = diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index 6e126822..e92d9bc6 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -20,6 +20,12 @@ import android.content.Context import android.util.PluralsMessageFormatter import androidx.annotation.StringRes import com.android.intentresolver.R +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject private const val PLURALS_COUNT = "count" @@ -27,7 +33,11 @@ private const val PLURALS_COUNT = "count" * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description * of the content being shared. */ -class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { +class HeadlineGeneratorImpl +@Inject +constructor( + @ApplicationContext private val context: Context, +) : HeadlineGenerator { override fun getTextHeadline(text: CharSequence): String { return context.getString( getTemplateResource(text, R.string.sharing_link, R.string.sharing_text) @@ -100,3 +110,9 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { return if (text.toString().isHttpUri()) linkResource else nonLinkResource } } + +@Module +@InstallIn(SingletonComponent::class) +interface HeadlineGeneratorModule { + @Binds fun bind(impl: HeadlineGeneratorImpl): HeadlineGenerator +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt new file mode 100644 index 00000000..7035f765 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.res.Resources +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +interface ImageLoaderModule { + @Binds + @ActivityRetainedScoped + fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader + + @Binds + @ActivityRetainedScoped + fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader + + companion object { + @Provides + @ThumbnailSize + fun thumbnailSize(@ApplicationOwned resources: Resources): Int = + resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) + + @Provides @PreviewCacheSize fun cacheSize() = 16 + + @Provides @PreviewMaxConcurrency fun maxConcurrency() = 4 + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt index 572ccf0b..fab7203e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -24,17 +24,31 @@ import android.util.Size import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.collection.LruCache +import com.android.intentresolver.inject.Background import java.util.function.Consumer +import javax.inject.Inject +import javax.inject.Qualifier import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore private const val TAG = "ImagePreviewImageLoader" +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewCacheSize + /** * Implements preview image loading for the content preview UI. Provides requests deduplication, * image caching, and a limit on the number of parallel loadings. @@ -52,6 +66,26 @@ constructor( private val contentResolverSemaphore: Semaphore, ) : ImageLoader { + @Inject + constructor( + @Background dispatcher: CoroutineDispatcher, + @ThumbnailSize thumbnailSize: Int, + contentResolver: ContentResolver, + @PreviewCacheSize cacheSize: Int, + ) : this( + CoroutineScope( + SupervisorJob() + + dispatcher + + CoroutineExceptionHandler { _, exception -> + Log.w(TAG, "Uncaught exception in ImageLoader", exception) + } + + CoroutineName("ImageLoader") + ), + thumbnailSize, + contentResolver, + cacheSize, + ) + constructor( scope: CoroutineScope, thumbnailSize: Int, diff --git a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt index 80232537..ac002ab6 100644 --- a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt +++ b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt @@ -15,13 +15,16 @@ */ @file:JvmName("HttpUriMatcher") + package com.android.intentresolver.contentpreview import java.net.URI internal fun String.isHttpUri() = - kotlin.runCatching { - URI(this).scheme.takeIf { scheme -> - "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 + kotlin + .runCatching { + URI(this).scheme.takeIf { scheme -> + "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 + } } - }.getOrNull() != null + .getOrNull() != null diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt index 31a7006c..924e6499 100644 --- a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt @@ -29,7 +29,7 @@ internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() { resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup?, - headlineViewParent: View?, + headlineViewParent: View, ): ViewGroup? { Log.e(TAG, "Unexpected content preview type: $type") return null diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt deleted file mode 100644 index eda5c4ca..00000000 --- a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt +++ /dev/null @@ -1,382 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.Intent -import android.content.IntentSender -import android.net.Uri -import android.service.chooser.ChooserAction -import android.service.chooser.ChooserTarget -import android.util.Log -import android.util.SparseArray -import java.io.Closeable -import java.util.LinkedList -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.channels.BufferOverflow.DROP_LATEST -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -private const val TAG = "PayloadToggleInteractor" - -@OptIn(ExperimentalCoroutinesApi::class) -class PayloadToggleInteractor( - // TODO: a single-thread dispatcher is currently expected. iterate on the synchronization logic. - private val scope: CoroutineScope, - private val initiallySharedUris: List<Uri>, - private val focusedUriIdx: Int, - private val mimeTypeClassifier: MimeTypeClassifier, - private val cursorReaderProvider: suspend () -> CursorReader, - private val uriMetadataReader: (Uri) -> FileInfo, - private val targetIntentModifier: (List<Item>) -> Intent, - private val selectionCallback: (Intent) -> ShareouselUpdate?, -) { - private var cursorDataRef = CompletableDeferred<CursorData?>() - private val records = LinkedList<Record>() - private val prevPageLoadingGate = AtomicBoolean(true) - private val nextPageLoadingGate = AtomicBoolean(true) - private val notifySelectionJobRef = AtomicReference<Job?>() - private val emptyState = - State( - emptyList(), - hasMoreItemsBefore = false, - hasMoreItemsAfter = false, - allowSelectionChange = false - ) - - private val stateFlowSource = MutableStateFlow(emptyState) - - val customActions = - MutableSharedFlow<List<ChooserAction>>(replay = 1, onBufferOverflow = DROP_LATEST) - - val stateFlow: Flow<State> - get() = stateFlowSource.filter { it !== emptyState } - - val targetPosition: Flow<Int> = stateFlow.map { it.targetPos } - val previewKeys: Flow<List<Item>> = stateFlow.map { it.items } - - fun getKey(item: Any): Int = (item as Item).key - - fun selected(key: Item): Flow<Boolean> = (key as Record).isSelected - - fun previewUri(key: Item): Flow<Uri?> = flow { emit(key.previewUri) } - - fun previewInteractor(key: Any): PayloadTogglePreviewInteractor { - val state = stateFlowSource.value - if (state === emptyState) { - Log.wtf(TAG, "Requesting item preview before any item has been published") - } else { - if (state.hasMoreItemsBefore && key === state.items.firstOrNull()) { - loadMorePreviousItems() - } - if (state.hasMoreItemsAfter && key == state.items.lastOrNull()) { - loadMoreNextItems() - } - } - return PayloadTogglePreviewInteractor(key as Item, this) - } - - init { - scope - .launch { awaitCancellation() } - .invokeOnCompletion { - cursorDataRef.cancel() - runCatching { - if (cursorDataRef.isCompleted && !cursorDataRef.isCancelled) { - cursorDataRef.getCompleted() - } else { - null - } - } - .getOrNull() - ?.reader - ?.close() - } - } - - fun start() { - scope.launch { - val cursorReader = cursorReaderProvider() - val selectedItems = - initiallySharedUris.map { uri -> - val fileInfo = uriMetadataReader(uri) - Record( - 0, // artificial key for the pending record, it should not be used anywhere - uri, - fileInfo.previewUri, - fileInfo.mimeType, - ) - } - val cursorData = - CursorData( - cursorReader, - SelectionTracker(selectedItems, focusedUriIdx, cursorReader.count) { uri }, - ) - if (cursorDataRef.complete(cursorData)) { - doLoadMorePreviousItems() - val startPos = records.size - doLoadMoreNextItems() - prevPageLoadingGate.set(false) - nextPageLoadingGate.set(false) - publishSnapshot(startPos) - } else { - cursorReader.close() - } - } - } - - fun loadMorePreviousItems() { - invokeAsyncIfNotRunning(prevPageLoadingGate) { - doLoadMorePreviousItems() - publishSnapshot() - } - } - - fun loadMoreNextItems() { - invokeAsyncIfNotRunning(nextPageLoadingGate) { - doLoadMoreNextItems() - publishSnapshot() - } - } - - fun setSelected(item: Item, isSelected: Boolean) { - val record = item as Record - scope.launch { - val (_, selectionTracker) = waitForCursorData() ?: return@launch - if (selectionTracker.setItemSelection(record.key, record, isSelected)) { - val targetIntent = targetIntentModifier(selectionTracker.getSelection()) - val newJob = scope.launch { notifySelectionChanged(targetIntent) } - notifySelectionJobRef.getAndSet(newJob)?.cancel() - record.isSelected.value = selectionTracker.isItemSelected(record.key) - } - } - } - - private fun invokeAsyncIfNotRunning(guardingFlag: AtomicBoolean, block: suspend () -> Unit) { - if (guardingFlag.compareAndSet(false, true)) { - scope.launch { block() }.invokeOnCompletion { guardingFlag.set(false) } - } - } - - private suspend fun doLoadMorePreviousItems() { - val (reader, selectionTracker) = waitForCursorData() ?: return - if (!reader.hasMoreBefore) return - - val newItems = reader.readPageBefore().toItems() - selectionTracker.onStartItemsAdded(newItems) - for (i in newItems.size() - 1 downTo 0) { - records.add( - 0, - (newItems.valueAt(i) as Record).apply { - isSelected.value = selectionTracker.isItemSelected(key) - } - ) - } - if (!reader.hasMoreBefore && !reader.hasMoreAfter) { - val pendingItems = selectionTracker.getPendingItems() - val newRecords = - pendingItems.foldIndexed(SparseArray<Item>()) { idx, acc, item -> - assert(item is Record) { "Unexpected pending item type: ${item.javaClass}" } - val rec = item as Record - val key = idx - pendingItems.size - acc.append( - key, - Record( - key, - rec.uri, - rec.previewUri, - rec.mimeType, - rec.mimeType?.mimeTypeToItemType() ?: ItemType.File - ) - ) - acc - } - - selectionTracker.onStartItemsAdded(newRecords) - for (i in (newRecords.size() - 1) downTo 0) { - records.add(0, (newRecords.valueAt(i) as Record).apply { isSelected.value = true }) - } - } - } - - private suspend fun doLoadMoreNextItems() { - val (reader, selectionTracker) = waitForCursorData() ?: return - if (!reader.hasMoreAfter) return - - val newItems = reader.readPageAfter().toItems() - selectionTracker.onEndItemsAdded(newItems) - for (i in 0 until newItems.size()) { - val key = newItems.keyAt(i) - records.add( - (newItems.valueAt(i) as Record).apply { - isSelected.value = selectionTracker.isItemSelected(key) - } - ) - } - if (!reader.hasMoreBefore && !reader.hasMoreAfter) { - val items = - selectionTracker.getPendingItems().let { items -> - items.foldIndexed(SparseArray<Item>(items.size)) { i, acc, item -> - val key = reader.count + i - val record = item as Record - acc.append( - key, - Record(key, record.uri, record.previewUri, record.mimeType, record.type) - ) - acc - } - } - selectionTracker.onEndItemsAdded(items) - for (i in 0 until items.size()) { - records.add((items.valueAt(i) as Record).apply { isSelected.value = true }) - } - } - } - - private fun SparseArray<Uri>.toItems(): SparseArray<Item> { - val items = SparseArray<Item>(size()) - for (i in 0 until size()) { - val key = keyAt(i) - val uri = valueAt(i) - val fileInfo = uriMetadataReader(uri) - items.append( - key, - Record( - key, - uri, - fileInfo.previewUri, - fileInfo.mimeType, - fileInfo.mimeType?.mimeTypeToItemType() ?: ItemType.File - ) - ) - } - return items - } - - private suspend fun waitForCursorData() = cursorDataRef.await() - - private fun notifySelectionChanged(targetIntent: Intent) { - selectionCallback(targetIntent)?.customActions?.let { customActions.tryEmit(it) } - } - - private suspend fun publishSnapshot(startPos: Int = -1) { - val (reader, _) = waitForCursorData() ?: return - // TODO: publish a view into the list as it can only grow on each side thus a view won't be - // invalidated - val items = ArrayList<Item>(records) - stateFlowSource.emit( - State( - items, - reader.hasMoreBefore, - reader.hasMoreAfter, - allowSelectionChange = true, - targetPos = startPos, - ) - ) - } - - private fun String.mimeTypeToItemType(): ItemType = - when { - mimeTypeClassifier.isImageType(this) -> ItemType.Image - mimeTypeClassifier.isVideoType(this) -> ItemType.Video - else -> ItemType.File - } - - class State( - val items: List<Item>, - val hasMoreItemsBefore: Boolean, - val hasMoreItemsAfter: Boolean, - val allowSelectionChange: Boolean, - val targetPos: Int = -1, - ) - - sealed interface Item { - val key: Int - val uri: Uri - val previewUri: Uri? - val mimeType: String? - val type: ItemType - } - - enum class ItemType { - Image, - Video, - File, - } - - private class Record( - override val key: Int, - override val uri: Uri, - override val previewUri: Uri? = uri, - override val mimeType: String?, - override val type: ItemType = ItemType.Image, - ) : Item { - val isSelected = MutableStateFlow(false) - } - - data class ShareouselUpdate( - // for all properties, null value means no change - val customActions: List<ChooserAction>? = null, - val modifyShareAction: ChooserAction? = null, - val alternateIntents: List<Intent>? = null, - val callerTargets: List<ChooserTarget>? = null, - val refinementIntentSender: IntentSender? = null, - ) - - private data class CursorData( - val reader: CursorReader, - val selectionTracker: SelectionTracker<Item>, - ) - - interface CursorReader : Closeable { - val count: Int - val hasMoreBefore: Boolean - val hasMoreAfter: Boolean - - fun readPageAfter(): SparseArray<Uri> - - fun readPageBefore(): SparseArray<Uri> - } -} - -class PayloadTogglePreviewInteractor( - private val item: PayloadToggleInteractor.Item, - private val interactor: PayloadToggleInteractor, -) { - fun setSelected(selected: Boolean) { - interactor.setSelected(item, selected) - } - - val previewUri: Flow<Uri?> - get() = interactor.previewUri(item) - - val selected: Flow<Boolean> - get() = interactor.selected(item) - - val key - get() = item.key -} diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index d694c6ff..6a729945 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -28,10 +28,8 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.R import com.android.intentresolver.inject.Background -import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.plus /** A view model for the preview logic */ @@ -42,9 +40,7 @@ class PreviewViewModel( @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BasePreviewViewModel() { private var targetIntent: Intent? = null - private var chooserIntent: Intent? = null private var additionalContentUri: Uri? = null - private var focusedItemIdx: Int = 0 private var isPayloadTogglingEnabled = false override val previewDataProvider by lazy { @@ -67,59 +63,19 @@ class PreviewViewModel( ) } - override val payloadToggleInteractor: PayloadToggleInteractor? by lazy { - val targetIntent = requireNotNull(targetIntent) { "Not initialized" } - // TODO: replace with flags injection - if (!isPayloadTogglingEnabled) return@lazy null - createPayloadToggleInteractor( - additionalContentUri ?: return@lazy null, - targetIntent, - chooserIntent ?: return@lazy null, - ) - .apply { start() } - } - // TODO: make the view model injectable and inject these dependencies instead @MainThread override fun init( targetIntent: Intent, - chooserIntent: Intent, additionalContentUri: Uri?, - focusedItemIdx: Int, isPayloadTogglingEnabled: Boolean, ) { if (this.targetIntent != null) return this.targetIntent = targetIntent - this.chooserIntent = chooserIntent this.additionalContentUri = additionalContentUri - this.focusedItemIdx = focusedItemIdx this.isPayloadTogglingEnabled = isPayloadTogglingEnabled } - private fun createPayloadToggleInteractor( - contentProviderUri: Uri, - targetIntent: Intent, - chooserIntent: Intent, - ): PayloadToggleInteractor { - return PayloadToggleInteractor( - // TODO: update PayloadToggleInteractor to support multiple threads - viewModelScope + Executors.newSingleThreadScheduledExecutor().asCoroutineDispatcher(), - previewDataProvider.uris, - maxOf(0, minOf(focusedItemIdx, previewDataProvider.uriCount - 1)), - DefaultMimeTypeClassifier, - { - CursorUriReader.createCursorReader( - contentResolver, - contentProviderUri, - chooserIntent - ) - }, - UriMetadataReader(contentResolver, DefaultMimeTypeClassifier), - TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }), - SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver) - ) - } - companion object { val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt deleted file mode 100644 index 6b33e1cd..00000000 --- a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.ContentInterface -import android.content.Intent -import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION -import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER -import android.content.Intent.EXTRA_CHOOSER_TARGETS -import android.content.Intent.EXTRA_INTENT -import android.content.IntentSender -import android.net.Uri -import android.os.Bundle -import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED -import android.service.chooser.ChooserAction -import android.service.chooser.ChooserTarget -import com.android.intentresolver.contentpreview.PayloadToggleInteractor.ShareouselUpdate -import com.android.intentresolver.v2.ui.viewmodel.readAlternateIntents -import com.android.intentresolver.v2.ui.viewmodel.readChooserActions -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.log -import com.android.intentresolver.v2.validation.types.array -import com.android.intentresolver.v2.validation.types.value -import com.android.intentresolver.v2.validation.validateFrom - -private const val TAG = "SelectionChangeCallback" - -/** - * Encapsulates payload change callback invocation to the sharing app; handles callback arguments - * and result format mapping. - */ -class SelectionChangeCallback( - private val uri: Uri, - private val chooserIntent: Intent, - private val contentResolver: ContentInterface, -) : (Intent) -> ShareouselUpdate? { - fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? = - contentResolver - .call( - requireNotNull(uri.authority) { "URI authority can not be null" }, - ON_SELECTION_CHANGED, - uri.toString(), - Bundle().apply { - putParcelable( - EXTRA_INTENT, - Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) } - ) - } - ) - ?.let { bundle -> - return when (val result = readCallbackResponse(bundle)) { - is Valid -> result.value - is Invalid -> { - result.errors.forEach { it.log(TAG) } - null - } - } - } - - override fun invoke(targetIntent: Intent) = onSelectionChanged(targetIntent) - - private fun readCallbackResponse(bundle: Bundle): ValidationResult<ShareouselUpdate> { - return validateFrom(bundle::get) { - val customActions = readChooserActions() - val modifyShareAction = - optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) - val alternateIntents = readAlternateIntents() - val callerTargets = optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS)) - val refinementIntentSender = - optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) - - ShareouselUpdate( - customActions, - modifyShareAction, - alternateIntents, - callerTargets, - refinementIntentSender, - ) - } - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt deleted file mode 100644 index c9431731..00000000 --- a/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.net.Uri -import android.util.SparseArray -import android.util.SparseIntArray -import androidx.core.util.containsKey -import androidx.core.util.isNotEmpty - -/** - * Tracks selected items (including those that has not been read frm the cursor) and their relative - * order. - */ -class SelectionTracker<Item>( - selectedItems: List<Item>, - private val focusedItemIdx: Int, - private val cursorCount: Int, - private val getUri: Item.() -> Uri, -) { - /** Contains selected items keys. */ - private val selections = SparseArray<Item>(selectedItems.size) - - /** - * A set of initially selected items that has not yet been observed by the lazy read of the - * cursor and thus has unknown key (cursor position). Initially, all [selectedItems] are put in - * this map with items at the index less than [focusedItemIdx] with negative keys (to the left - * of all cursor items) and items at the index more or equal to [focusedItemIdx] with keys more - * or equal to [cursorCount] (to the right of all cursor items) in their relative order. Upon - * reading the cursor, [onEndItemsAdded]/[onStartItemsAdded], all pending items from that - * collection in the corresponding direction get their key assigned and gets removed from the - * map. Items that were missing from the cursor get removed from the map by - * [getPendingItems] + [onStartItemsAdded]/[onEndItemsAdded] combination. - */ - private val pendingKeys = HashMap<Uri, SparseIntArray>() - - init { - selectedItems.forEachIndexed { i, item -> - // all items before focusedItemIdx gets "positioned" before all the cursor items - // and all the reset after all the cursor items in their relative order. - // Also see the comments to pendingKeys property. - val key = - if (i < focusedItemIdx) { - i - focusedItemIdx - } else { - i + cursorCount - focusedItemIdx - } - selections.append(key, item) - pendingKeys.getOrPut(item.getUri()) { SparseIntArray(1) }.append(key, key) - } - } - - /** Update selections based on the set of items read from the end of the cursor */ - fun onEndItemsAdded(items: SparseArray<Item>) { - for (i in 0 until items.size()) { - val item = items.valueAt(i) - pendingKeys[item.getUri()] - // if only one pending (unmatched) item with this URI is left, removed this URI - ?.also { - if (it.size() <= 1) { - pendingKeys.remove(item.getUri()) - } - } - // a safeguard, we should not observe empty arrays at this point - ?.takeIf { it.isNotEmpty() } - // pick a matching pending items from the right side - ?.let { pendingUriPositions -> - val key = items.keyAt(i) - val insertPos = - pendingUriPositions - .findBestKeyPosition(key) - .coerceIn(0, pendingUriPositions.size() - 1) - // select next pending item from the right, if not such item exists then - // the data is inconsistent and we pick the closes one from the left - val keyPlaceholder = pendingUriPositions.keyAt(insertPos) - pendingUriPositions.removeAt(insertPos) - selections.remove(keyPlaceholder) - selections[key] = item - } - } - } - - /** Update selections based on the set of items read from the head of the cursor */ - fun onStartItemsAdded(items: SparseArray<Item>) { - for (i in (items.size() - 1) downTo 0) { - val item = items.valueAt(i) - pendingKeys[item.getUri()] - // if only one pending (unmatched) item with this URI is left, removed this URI - ?.also { - if (it.size() <= 1) { - pendingKeys.remove(item.getUri()) - } - } - // a safeguard, we should not observe empty arrays at this point - ?.takeIf { it.isNotEmpty() } - // pick a matching pending items from the left side - ?.let { pendingUriPositions -> - val key = items.keyAt(i) - val insertPos = - pendingUriPositions - .findBestKeyPosition(key) - .coerceIn(1, pendingUriPositions.size()) - // select next pending item from the left, if not such item exists then - // the data is inconsistent and we pick the closes one from the right - val keyPlaceholder = pendingUriPositions.keyAt(insertPos - 1) - pendingUriPositions.removeAt(insertPos - 1) - selections.remove(keyPlaceholder) - selections[key] = item - } - } - } - - /** Updated selection status for the given item */ - fun setItemSelection(key: Int, item: Item, isSelected: Boolean): Boolean { - val idx = selections.indexOfKey(key) - if (isSelected && idx < 0) { - selections[key] = item - return true - } - if (!isSelected && idx >= 0 && selections.size() > 1) { - selections.removeAt(idx) - return true - } - return false - } - - /** Return selection status for the given item */ - fun isItemSelected(key: Int): Boolean = selections.containsKey(key) - - fun getSelection(): List<Item> = - buildList(selections.size()) { - for (i in 0 until selections.size()) { - add(selections.valueAt(i)) - } - } - - /** Return all selected items that has not yet been read from the cursor */ - fun getPendingItems(): List<Item> = - if (pendingKeys.isEmpty()) { - emptyList() - } else { - buildList { - for (i in 0 until selections.size()) { - val item = selections.valueAt(i) ?: continue - if (isPending(item, selections.keyAt(i))) { - add(item) - } - } - } - } - - private fun isPending(item: Item, key: Int): Boolean { - val keys = pendingKeys[item.getUri()] ?: return false - return keys.containsKey(key) - } - - private fun SparseIntArray.findBestKeyPosition(key: Int): Int = - // undocumented, but indexOfKey behaves in the same was as - // java.util.Collections#binarySearch() - indexOfKey(key).let { if (it < 0) it.inv() else it } -} diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 82c09986..57a51239 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -22,32 +22,22 @@ import android.view.ViewGroup import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.dimensionResource -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.android.intentresolver.R -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.contentpreview.shareousel.ui.composable.Shareousel -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.toShareouselViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.ui.viewmodel.ChooserViewModel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) -class ShareouselContentPreviewUi( - private val actionFactory: ActionFactory, -) : ContentPreviewUi() { +class ShareouselContentPreviewUi : ContentPreviewUi() { override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE @@ -55,77 +45,62 @@ class ShareouselContentPreviewUi( resources: Resources, layoutInflater: LayoutInflater, parent: ViewGroup, - headlineViewParent: View?, - ): ViewGroup { - return displayInternal(parent, headlineViewParent).also { layout -> - displayModifyShareAction(headlineViewParent ?: layout, actionFactory) + headlineViewParent: View, + ): ViewGroup = displayInternal(parent, headlineViewParent) + + private fun displayInternal(parent: ViewGroup, headlineViewParent: View): ViewGroup { + inflateHeadline(headlineViewParent) + return ComposeView(parent.context).apply { + setContent { + val vm: ChooserViewModel = viewModel() + val viewModel: ShareouselViewModel = vm.shareouselViewModel + + LaunchedEffect(viewModel) { bindHeader(viewModel, headlineViewParent) } + + MaterialTheme( + colorScheme = + if (isSystemInDarkTheme()) { + dynamicDarkColorScheme(LocalContext.current) + } else { + dynamicLightColorScheme(LocalContext.current) + }, + ) { + Shareousel(viewModel) + } + } } } - private fun displayInternal( - parent: ViewGroup, - headlineViewParent: View?, - ): ViewGroup { - if (headlineViewParent != null) { - inflateHeadline(headlineViewParent) + private suspend fun bindHeader(viewModel: ShareouselViewModel, headlineViewParent: View) { + coroutineScope { + launch { bindHeadline(viewModel, headlineViewParent) } + launch { bindMetadataText(viewModel, headlineViewParent) } } - val composeView = - ComposeView(parent.context).apply { - setContent { - val vm: BasePreviewViewModel = viewModel() - val interactor = - requireNotNull(vm.payloadToggleInteractor) { "Should not be null" } - - var viewModel by remember { mutableStateOf<ShareouselViewModel?>(null) } - LaunchedEffect(Unit) { - viewModel = - interactor.toShareouselViewModel( - vm.imageLoader, - actionFactory, - vm.viewModelScope - ) - } + } - headlineViewParent?.let { - viewModel?.let { viewModel -> - LaunchedEffect(viewModel) { - viewModel.headline.collect { headline -> - headlineViewParent - .findViewById<TextView>(R.id.headline) - ?.apply { - if (headline.isNotBlank()) { - text = headline - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - } - } - } - } + private suspend fun bindHeadline(viewModel: ShareouselViewModel, headlineViewParent: View) { + viewModel.headline.collect { headline -> + headlineViewParent.findViewById<TextView>(R.id.headline)?.apply { + if (headline.isNotBlank()) { + text = headline + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + } + } - viewModel?.let { viewModel -> - MaterialTheme( - colorScheme = - if (isSystemInDarkTheme()) { - dynamicDarkColorScheme(LocalContext.current) - } else { - dynamicLightColorScheme(LocalContext.current) - }, - ) { - Shareousel(viewModel = viewModel) - } - } - ?: run { - Spacer( - Modifier.height( - dimensionResource(R.dimen.chooser_preview_image_height_tall) - ) - ) - } + private suspend fun bindMetadataText(viewModel: ShareouselViewModel, headlineViewParent: View) { + viewModel.metadataText.collect { metadata -> + headlineViewParent.findViewById<TextView>(R.id.metadata)?.apply { + if (metadata?.isNotBlank() == true) { + text = metadata + visibility = View.VISIBLE + } else { + visibility = View.GONE } } - return composeView + } } } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index fbdc5853..ae7ddcd9 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -82,22 +82,16 @@ class TextContentPreviewUi extends ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { - ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); - displayModifyShareAction( - headlineViewParent == null ? layout : headlineViewParent, mActionFactory); - return layout; + View headlineViewParent) { + return displayInternal(layoutInflater, parent, headlineViewParent); } private ViewGroup displayInternal( LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { + View headlineViewParent) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); - if (headlineViewParent == null) { - headlineViewParent = contentPreviewLayout; - } inflateHeadline(headlineViewParent); final ActionRow actionRow = diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt new file mode 100644 index 00000000..9f1d50da --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import android.util.Size +import javax.inject.Inject + +/** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */ +interface ThumbnailLoader : suspend (Uri) -> Bitmap? + +/** Default implementation of [ThumbnailLoader]. */ +class ThumbnailLoaderImpl +@Inject +constructor( + private val contentResolver: ContentResolver, + @ThumbnailSize thumbnailSize: Int, +) : ThumbnailLoader { + + private val size = Size(thumbnailSize, thumbnailSize) + + override suspend fun invoke(uri: Uri): Bitmap = + contentResolver.loadThumbnail(uri, size, /* signal = */ null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 0974c79b..88311016 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -31,12 +31,12 @@ import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import com.android.intentresolver.widget.ScrollableImagePreviewView; -import java.util.List; -import java.util.Objects; - import kotlinx.coroutines.CoroutineScope; import kotlinx.coroutines.flow.Flow; +import java.util.List; +import java.util.Objects; + class UnifiedContentPreviewUi extends ContentPreviewUi { private final boolean mShowEditAction; @Nullable @@ -54,7 +54,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { private List<FileInfo> mFiles; @Nullable private ViewGroup mContentPreviewView; - @Nullable private View mHeadlineView; UnifiedContentPreviewUi( @@ -93,11 +92,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { Resources resources, LayoutInflater layoutInflater, ViewGroup parent, - @Nullable View headlineViewParent) { - ViewGroup layout = displayInternal(layoutInflater, parent, headlineViewParent); - displayModifyShareAction( - headlineViewParent == null ? layout : headlineViewParent, mActionFactory); - return layout; + View headlineViewParent) { + return displayInternal(layoutInflater, parent, headlineViewParent); } private void setFiles(List<FileInfo> files) { @@ -112,10 +108,10 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } private ViewGroup displayInternal( - LayoutInflater layoutInflater, ViewGroup parent, @Nullable View headlineViewParent) { + LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) { mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); - mHeadlineView = headlineViewParent == null ? mContentPreviewView : headlineViewParent; + mHeadlineView = headlineViewParent; inflateHeadline(mHeadlineView); final ActionRow actionRow = diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt index 41638b1f..c532b9a5 100644 --- a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt @@ -23,9 +23,12 @@ import android.net.Uri import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL import android.provider.Downloads +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH import android.provider.OpenableColumns import android.text.TextUtils import android.util.Log +import android.util.Size import com.android.intentresolver.measurements.runTracing internal fun ContentInterface.getTypeSafe(uri: Uri): String? = @@ -83,6 +86,25 @@ internal fun Cursor.readPreviewUri(): Uri? = } .getOrNull() +fun Cursor.readSize(): Size? { + val widthIdx = columnNames.indexOf(WIDTH) + val heightIdx = columnNames.indexOf(HEIGHT) + return if (widthIdx < 0 || heightIdx < 0 || isNull(widthIdx) || isNull(heightIdx)) { + null + } else { + runCatching { + val width = getInt(widthIdx) + val height = getInt(heightIdx) + if (width >= 0 && height > 0) { + Size(width, height) + } else { + null + } + } + .getOrNull() + } +} + internal fun Cursor.readTitle(): String = runCatching { var nameColIndex = -1 diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt index 45515e25..4e403c22 100644 --- a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt @@ -20,12 +20,27 @@ import android.content.ContentInterface import android.media.MediaMetadata import android.net.Uri import android.provider.DocumentsContract +import android.provider.MediaStore.MediaColumns +import android.util.Size +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject -class UriMetadataReader( +fun interface UriMetadataReader { + fun getMetadata(uri: Uri): FileInfo + fun readPreviewSize(uri: Uri): Size? = null +} + +class UriMetadataReaderImpl +@Inject +constructor( private val contentResolver: ContentInterface, private val typeClassifier: MimeTypeClassifier, -) : (Uri) -> FileInfo { - fun getMetadata(uri: Uri): FileInfo { +) : UriMetadataReader { + override fun getMetadata(uri: Uri): FileInfo { val builder = FileInfo.Builder(uri) val mimeType = contentResolver.getTypeSafe(uri) builder.withMimeType(mimeType) @@ -44,7 +59,7 @@ class UriMetadataReader( return builder.build() } - override fun invoke(uri: Uri): FileInfo = getMetadata(uri) + override fun readPreviewSize(uri: Uri): Size? = contentResolver.readPreviewSize(uri) private fun ContentInterface.supportsImageType(uri: Uri): Boolean = getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null @@ -63,4 +78,24 @@ class UriMetadataReader( null } } + + private fun ContentInterface.readPreviewSize(uri: Uri): Size? = + querySafe(uri, arrayOf(MediaColumns.WIDTH, MediaColumns.HEIGHT))?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.readSize() + } else { + null + } + } +} + +@Module +@InstallIn(SingletonComponent::class) +interface UriMetadataReaderModule { + + @Binds fun bind(impl: UriMetadataReaderImpl): UriMetadataReader + + companion object { + @Provides fun classifier(): MimeTypeClassifier = DefaultMimeTypeClassifier + } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt new file mode 100644 index 00000000..b7945005 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.model + +import android.graphics.drawable.Icon + +/** Data model for a custom action the user can take. */ +data class CustomActionModel( + /** Label presented to the user identifying this action. */ + val label: CharSequence, + /** Icon presented to the user for this action. */ + val icon: Icon, + /** When invoked, performs this action. */ + val performAction: () -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt new file mode 100644 index 00000000..c3bb88c8 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Tracks the result of the current activity. */ +@ActivityRetainedScoped +class ActivityResultRepository @Inject constructor() { + /** The result of the current activity, or `null` if the activity is still active. */ + val activityResult = MutableStateFlow<Int?>(null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt new file mode 100644 index 00000000..b104d4bf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Stores previews for Shareousel UI that have been cached locally from a remote + * [android.database.Cursor]. + */ +@ActivityRetainedScoped +class CursorPreviewsRepository @Inject constructor() { + /** Previews available for display within Shareousel. */ + val previewsModel = MutableStateFlow<PreviewsModel?>(null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt new file mode 100644 index 00000000..1745cd9c --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import android.content.Intent +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Tracks active async communication with sharing app to notify of target intent update. */ +@ActivityRetainedScoped +class PendingSelectionCallbackRepository @Inject constructor() { + /** + * The target [Intent] that is has an active update request with the sharing app, or `null` if + * there is no active request. + */ + val pendingTargetIntent: MutableStateFlow<Intent?> = MutableStateFlow(null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt new file mode 100644 index 00000000..9aecc981 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Stores set of selected previews. */ +@ViewModelScoped +class PreviewSelectionsRepository @Inject constructor() { + val selections = MutableStateFlow(emptySet<PreviewModel>()) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt new file mode 100644 index 00000000..3aa0d567 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor + +import com.android.intentresolver.util.cursor.CursorView + +/** Asynchronously retrieves a [CursorView]. */ +fun interface CursorResolver<out T> { + suspend fun getCursor(): CursorView<T>? +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt new file mode 100644 index 00000000..d9612696 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor + +import android.content.ContentInterface +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.service.chooser.AdditionalContentContract.Columns.URI +import androidx.core.os.bundleOf +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.readSize +import com.android.intentresolver.inject.AdditionalContent +import com.android.intentresolver.inject.ChooserIntent +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.viewBy +import com.android.intentresolver.util.withCancellationSignal +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Inject +import javax.inject.Qualifier + +/** [CursorResolver] for the [CursorView] underpinning Shareousel. */ +class PayloadToggleCursorResolver +@Inject +constructor( + private val contentResolver: ContentInterface, + @AdditionalContent private val cursorUri: Uri, + @ChooserIntent private val chooserIntent: Intent, +) : CursorResolver<CursorRow?> { + override suspend fun getCursor(): CursorView<CursorRow?>? = withCancellationSignal { signal -> + runCatching { + contentResolver.query( + cursorUri, + // TODO: uncomment to start using that data + arrayOf(URI /*, WIDTH, HEIGHT*/), + bundleOf(Intent.EXTRA_INTENT to chooserIntent), + signal, + ) + } + .getOrNull() + ?.viewBy { readUri()?.let { uri -> CursorRow(uri, readSize()) } } + } + + private fun Cursor.readUri(): Uri? { + val uriIdx = columnNames.indexOf(URI) + if (uriIdx < 0) return null + return runCatching { + getString(uriIdx)?.let(Uri::parse)?.takeIf { it.authority != cursorUri.authority } + } + .getOrNull() + } + + @Module + @InstallIn(ViewModelComponent::class) + interface Binding { + @Binds + @PayloadToggle + fun bind(cursorResolver: PayloadToggleCursorResolver): CursorResolver<CursorRow?> + } +} + +/** [CursorResolver] for the [CursorView] underpinning Shareousel. */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PayloadToggle diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt new file mode 100644 index 00000000..faad5bbf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.app.ActivityOptions +import android.app.PendingIntent +import android.content.Context +import com.android.intentresolver.R +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject +import javax.inject.Qualifier + +/** [PendingIntentSender] for Shareousel custom actions. */ +class CustomActionPendingIntentSender +@Inject +constructor( + @ApplicationContext private val context: Context, +) : PendingIntentSender { + override fun send(pendingIntent: PendingIntent) { + pendingIntent.send( + /* context = */ null, + /* code = */ 0, + /* intent = */ null, + /* onFinished = */ null, + /* handler = */ null, + /* requiredPermission = */ null, + /* options = */ ActivityOptions.makeCustomAnimation( + context, + R.anim.slide_in_right, + R.anim.slide_out_left, + ) + .toBundle() + ) + } + + @Module + @InstallIn(SingletonComponent::class) + interface Binding { + @Binds + @CustomAction + fun bindSender(sender: CustomActionPendingIntentSender): PendingIntentSender + } +} + +/** [PendingIntentSender] for Shareousel custom actions. */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class CustomAction diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt new file mode 100644 index 00000000..d75884d5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.app.PendingIntent +import android.service.chooser.ChooserAction +import android.util.Log +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +object InitialCustomActionsModule { + @Provides + fun initialCustomActionModels( + chooserActions: List<ChooserAction>, + @CustomAction pendingIntentSender: PendingIntentSender, + ): List<CustomActionModel> = chooserActions.map { it.toCustomActionModel(pendingIntentSender) } +} + +/** + * Returns a [CustomActionModel] that sends this [ChooserAction]'s + * [PendingIntent][ChooserAction.getAction]. + */ +fun ChooserAction.toCustomActionModel(pendingIntentSender: PendingIntentSender) = + CustomActionModel( + label = label, + icon = icon, + performAction = { + try { + pendingIntentSender.send(action) + } catch (_: PendingIntent.CanceledException) { + Log.d(TAG, "Custom action, $label, has been cancelled") + } + } + ) + +private const val TAG = "CustomShareActions" diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt index 4ddab755..23ba31ba 100644 --- a/java/src/com/android/intentresolver/v2/listcontroller/ListController.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,8 +14,11 @@ * limitations under the License. */ -package com.android.intentresolver.v2.listcontroller +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent -/** Controller for managing lists of [com.android.intentresolver.ResolvedComponentInfo]s. */ -interface ListController : - LastChosenManager, IntentResolver, ResolvedComponentFiltering, ResolvedComponentSorting +import android.app.PendingIntent + +/** Sends [PendingIntent]s. */ +fun interface PendingIntentSender { + fun send(pendingIntent: PendingIntent) +} diff --git a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt index 58da5bc4..4a2a6932 100644 --- a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.contentpreview +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent import android.content.ClipData import android.content.ClipDescription.compareMimeTypes @@ -23,29 +23,38 @@ import android.content.Intent.ACTION_SEND import android.content.Intent.ACTION_SEND_MULTIPLE import android.content.Intent.EXTRA_STREAM import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.TargetIntent +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent /** Modifies target intent based on current payload selection. */ -class TargetIntentModifier<Item>( +fun interface TargetIntentModifier<Item> { + fun intentFromSelection(selection: Collection<Item>): Intent +} + +class TargetIntentModifierImpl<Item>( private val originalTargetIntent: Intent, private val getUri: Item.() -> Uri, private val getMimeType: Item.() -> String?, -) : (List<Item>) -> Intent { - fun onSelectionChanged(selection: List<Item>): Intent { - val uris = ArrayList<Uri>(selection.size) - var targetMimeType: String? = null - for (item in selection) { - targetMimeType = updateMimeType(item.getMimeType(), targetMimeType) - uris.add(item.getUri()) - } - val action = if (uris.size == 1) ACTION_SEND else ACTION_SEND_MULTIPLE +) : TargetIntentModifier<Item> { + override fun intentFromSelection(selection: Collection<Item>): Intent { + val uris = selection.mapTo(ArrayList()) { it.getUri() } + val targetMimeType = + selection.fold(null) { target: String?, item: Item -> + updateMimeType(item.getMimeType(), target) + } return Intent(originalTargetIntent).apply { - this.action = action - this.type = targetMimeType - if (action == ACTION_SEND) { - putExtra(EXTRA_STREAM, uris[0]) + if (selection.size == 1) { + action = ACTION_SEND + putExtra(EXTRA_STREAM, selection.first().getUri()) } else { + action = ACTION_SEND_MULTIPLE putParcelableArrayListExtra(EXTRA_STREAM, uris) } + type = targetMimeType if (uris.isNotEmpty()) { clipData = ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { @@ -70,6 +79,14 @@ class TargetIntentModifier<Item>( } return "*/*" } +} - override fun invoke(selection: List<Item>): Intent = onSelectionChanged(selection) +@Module +@InstallIn(ViewModelComponent::class) +object TargetIntentModifierModule { + @Provides + fun targetIntentModifier( + @TargetIntent targetIntent: Intent, + ): TargetIntentModifier<PreviewModel> = + TargetIntentModifierImpl(targetIntent, { uri }, { mimeType }) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt new file mode 100644 index 00000000..953e91b3 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.data.repository.ChooserRequestRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map + +/** Stores the target intent of the share sheet, and custom actions derived from the intent. */ +class ChooserRequestInteractor +@Inject +constructor( + private val repository: ChooserRequestRepository, +) { + val targetIntent: Flow<Intent> + get() = repository.chooserRequest.map { it.targetIntent } + + val customActions: Flow<List<CustomActionModel>> + get() = repository.customActions.asSharedFlow() + + val metadataText: Flow<CharSequence?> + get() = repository.chooserRequest.map { it.metadataText } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt new file mode 100644 index 00000000..f02834e0 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -0,0 +1,337 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadedWindow +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowLeft +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowRight +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.numLoadedPages +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowLeft +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowRight +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.FocusedItemIndex +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.PagedCursor +import com.android.intentresolver.util.cursor.get +import com.android.intentresolver.util.cursor.paged +import com.android.intentresolver.util.mapParallel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest + +/** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */ +class CursorPreviewsInteractor +@Inject +constructor( + private val interactor: SetCursorPreviewsInteractor, + @FocusedItemIndex private val focusedItemIdx: Int, + private val uriMetadataReader: UriMetadataReader, + @PageSize private val pageSize: Int, + @MaxLoadedPages private val maxLoadedPages: Int, +) { + + init { + check(pageSize > 0) { "pageSize must be greater than zero" } + } + + /** Start reading data from [uriCursor], and listen for requests to load more. */ + suspend fun launch(uriCursor: CursorView<CursorRow?>, initialPreviews: Iterable<PreviewModel>) { + // Unclaimed values from the initial selection set. Entries will be removed as the cursor is + // read, and any still present are inserted at the start / end of the cursor when it is + // reached by the user. + val unclaimedRecords: MutableUnclaimedMap = + initialPreviews + .asSequence() + .mapIndexed { i, m -> Pair(m.uri, Pair(i, m)) } + .toMap(ConcurrentHashMap()) + val pagedCursor: PagedCursor<CursorRow?> = uriCursor.paged(pageSize) + val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0 + val state = + loadToMaxPages( + initialState = readInitialState(pagedCursor, startPosition, unclaimedRecords), + pagedCursor = pagedCursor, + unclaimedRecords = unclaimedRecords, + ) + processLoadRequests(state, pagedCursor, unclaimedRecords) + } + + private suspend fun loadToMaxPages( + initialState: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + var state = initialState + val startPageNum = state.firstLoadedPageNum + while ((state.hasMoreLeft || state.hasMoreRight) && state.numLoadedPages < maxLoadedPages) { + interactor.setPreviews( + previewsByKey = state.merged.values.toSet(), + startIndex = startPageNum, + hasMoreLeft = state.hasMoreLeft, + hasMoreRight = state.hasMoreRight, + ) + val loadedLeft = startPageNum - state.firstLoadedPageNum + val loadedRight = state.lastLoadedPageNum - startPageNum + state = + when { + state.hasMoreLeft && loadedLeft < loadedRight -> + state.loadMoreLeft(pagedCursor, unclaimedRecords) + state.hasMoreRight -> state.loadMoreRight(pagedCursor, unclaimedRecords) + else -> state.loadMoreLeft(pagedCursor, unclaimedRecords) + } + } + return state + } + + /** Loop forever, processing any loading requests from the UI and updating local cache. */ + private suspend fun processLoadRequests( + initialState: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ) { + var state = initialState + while (true) { + // Design note: in order to prevent load requests from the UI when it was displaying a + // previously-published dataset being accidentally associated with a recently-published + // one, we generate a new Flow of load requests for each dataset and only listen to + // those. + val loadingState: Flow<LoadDirection?> = + interactor.setPreviews( + previewsByKey = state.merged.values.toSet(), + startIndex = 0, // TODO: actually track this as the window changes? + hasMoreLeft = state.hasMoreLeft, + hasMoreRight = state.hasMoreRight, + ) + state = loadingState.handleOneLoadRequest(state, pagedCursor, unclaimedRecords) + } + } + + /** + * Suspends until a single loading request has been handled, returning the new [CursorWindow] + * with the loaded data incorporated. + */ + private suspend fun Flow<LoadDirection?>.handleOneLoadRequest( + state: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow = + mapLatest { loadDirection -> + loadDirection?.let { + when (loadDirection) { + LoadDirection.Left -> state.loadMoreLeft(pagedCursor, unclaimedRecords) + LoadDirection.Right -> state.loadMoreRight(pagedCursor, unclaimedRecords) + } + } + } + .filterNotNull() + .first() + + /** + * Returns the initial [CursorWindow], with a single page loaded that contains the given + * [startPosition]. + */ + private suspend fun readInitialState( + cursor: PagedCursor<CursorRow?>, + startPosition: Int, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val startPageIdx = startPosition / pageSize + val hasMoreLeft = startPageIdx > 0 + val hasMoreRight = startPageIdx < cursor.count - 1 + val page: PreviewMap = buildMap { + if (!hasMoreLeft) { + // First read the initial page; this might claim some unclaimed Uris + val page = + cursor.getPageRows(startPageIdx)?.toPage(mutableMapOf(), unclaimedRecords) + // Now that unclaimed Uris are up-to-date, add them first. + putAllUnclaimedLeft(unclaimedRecords) + // Then add the loaded page + page?.let(::putAll) + } else { + cursor.getPageRows(startPageIdx)?.toPage(this, unclaimedRecords) + } + // Finally, add the remainder of the unclaimed Uris. + if (!hasMoreRight) { + putAllUnclaimedRight(unclaimedRecords) + } + } + return CursorWindow( + firstLoadedPageNum = startPageIdx, + lastLoadedPageNum = startPageIdx, + pages = listOf(page.keys), + merged = page, + hasMoreLeft = hasMoreLeft, + hasMoreRight = hasMoreRight, + ) + } + + private suspend fun CursorWindow.loadMoreRight( + cursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val pageNum = lastLoadedPageNum + 1 + val hasMoreRight = pageNum < cursor.count - 1 + val newPage: PreviewMap = buildMap { + readAndPutPage(this@loadMoreRight, cursor, pageNum, unclaimedRecords) + if (!hasMoreRight) { + putAllUnclaimedRight(unclaimedRecords) + } + } + return if (numLoadedPages < maxLoadedPages) { + expandWindowRight(newPage, hasMoreRight) + } else { + shiftWindowRight(newPage, hasMoreRight) + } + } + + private suspend fun CursorWindow.loadMoreLeft( + cursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val pageNum = firstLoadedPageNum - 1 + val hasMoreLeft = pageNum > 0 + val newPage: PreviewMap = buildMap { + if (!hasMoreLeft) { + // First read the page; this might claim some unclaimed Uris + val page = readPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + // Now that unclaimed URIs are up-to-date, add them first + putAllUnclaimedLeft(unclaimedRecords) + // Then add the loaded page + putAll(page) + } else { + readAndPutPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + } + } + return if (numLoadedPages < maxLoadedPages) { + expandWindowLeft(newPage, hasMoreLeft) + } else { + shiftWindowLeft(newPage, hasMoreLeft) + } + } + + private suspend fun readPage( + state: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + pageNum: Int, + unclaimedRecords: MutableUnclaimedMap, + ): PreviewMap = + mutableMapOf<Uri, PreviewModel>() + .readAndPutPage(state, pagedCursor, pageNum, unclaimedRecords) + + private suspend fun <M : MutablePreviewMap> M.readAndPutPage( + state: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + pageNum: Int, + unclaimedRecords: MutableUnclaimedMap, + ): M = + pagedCursor + .getPageRows(pageNum) // TODO: what do we do if the load fails? + ?.filter { it.uri !in state.merged } + ?.toPage(this, unclaimedRecords) + ?: this + + private suspend fun <M : MutablePreviewMap> Sequence<CursorRow>.toPage( + destination: M, + unclaimedRecords: MutableUnclaimedMap, + ): M = + // Restrict parallelism so as to not overload the metadata reader; anecdotally, too + // many parallel queries causes failures. + mapParallel(parallelism = 4) { row -> createPreviewModel(row, unclaimedRecords) } + .associateByTo(destination) { it.uri } + + private fun createPreviewModel( + row: CursorRow, + unclaimedRecords: MutableUnclaimedMap, + ): PreviewModel = + unclaimedRecords.remove(row.uri)?.second + ?: uriMetadataReader.getMetadata(row.uri).let { metadata -> + val size = + row.previewSize + ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } + PreviewModel( + uri = row.uri, + previewUri = metadata.previewUri, + mimeType = metadata.mimeType, + aspectRatio = size.aspectRatioOrDefault(1f), + ) + } + + private fun <M : MutablePreviewMap> M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx } + + private fun <M : MutablePreviewMap> M.putAllUnclaimedLeft(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it < focusedItemIdx } +} + +private typealias CursorWindow = LoadedWindow<Uri, PreviewModel> + +/** + * Values from the initial selection set that have not yet appeared within the Cursor. These values + * are appended to the start/end of the cursor dataset, depending on their position relative to the + * initially focused value. + */ +private typealias UnclaimedMap = Map<Uri, Pair<Int, PreviewModel>> + +/** Mutable version of [UnclaimedMap]. */ +private typealias MutableUnclaimedMap = MutableMap<Uri, Pair<Int, PreviewModel>> + +private typealias MutablePreviewMap = MutableMap<Uri, PreviewModel> + +private typealias PreviewMap = Map<Uri, PreviewModel> + +private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere( + unclaimedRecords: UnclaimedMap, + predicate: (Int) -> Boolean, +): M = + unclaimedRecords + .asSequence() + .filter { predicate(it.value.first) } + .map { it.key to it.value.second } + .toMap(this) + +private fun PagedCursor<CursorRow?>.getPageRows(pageNum: Int): Sequence<CursorRow>? = + get(pageNum)?.filterNotNull() + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class MaxLoadedPages + +@Module +@InstallIn(SingletonComponent::class) +object ShareouselConstants { + @Provides @PageSize fun pageSize(): Int = 16 + + @Provides @MaxLoadedPages fun maxLoadedPages(): Int = 8 +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt new file mode 100644 index 00000000..e973e844 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.app.Activity +import android.content.ContentResolver +import android.content.pm.PackageManager +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel +import com.android.intentresolver.icon.toComposeIcon +import com.android.intentresolver.inject.Background +import com.android.intentresolver.logging.EventLog +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +class CustomActionsInteractor +@Inject +constructor( + private val activityResultRepo: ActivityResultRepository, + @Background private val bgDispatcher: CoroutineDispatcher, + private val contentResolver: ContentResolver, + private val eventLog: EventLog, + private val packageManager: PackageManager, + private val chooserRequestInteractor: ChooserRequestInteractor, +) { + /** List of [ActionModel] that can be presented in Shareousel. */ + val customActions: Flow<List<ActionModel>> + get() = + chooserRequestInteractor.customActions + .map { actions -> + actions.map { action -> + ActionModel( + label = action.label, + icon = action.icon.toComposeIcon(packageManager, contentResolver), + performAction = { index -> performAction(action, index) }, + ) + } + } + .flowOn(bgDispatcher) + .conflate() + + private fun performAction(action: CustomActionModel, index: Int) { + action.performAction() + eventLog.logCustomActionSelected(index) + activityResultRepo.activityResult.value = Activity.RESULT_OK + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt new file mode 100644 index 00000000..80cd03d9 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.ContentUris +import com.android.intentresolver.inject.FocusedItemIndex +import com.android.intentresolver.util.mapParallel +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** Populates the data displayed in Shareousel. */ +class FetchPreviewsInteractor +@Inject +constructor( + private val setCursorPreviews: SetCursorPreviewsInteractor, + private val selectionRepository: PreviewSelectionsRepository, + private val cursorInteractor: CursorPreviewsInteractor, + @FocusedItemIndex private val focusedItemIdx: Int, + @ContentUris private val selectedItems: List<@JvmSuppressWildcards Uri>, + private val uriMetadataReader: UriMetadataReader, + @PayloadToggle private val cursorResolver: CursorResolver<@JvmSuppressWildcards CursorRow?>, +) { + suspend fun activate() = coroutineScope { + val cursor = async { cursorResolver.getCursor() } + val initialPreviewMap: Set<PreviewModel> = getInitialPreviews() + selectionRepository.selections.value = initialPreviewMap + setCursorPreviews.setPreviews( + previewsByKey = initialPreviewMap, + startIndex = focusedItemIdx, + hasMoreLeft = false, + hasMoreRight = false, + ) + cursorInteractor.launch(cursor.await() ?: return@coroutineScope, initialPreviewMap) + } + + private suspend fun getInitialPreviews(): Set<PreviewModel> = + selectedItems + // Restrict parallelism so as to not overload the metadata reader; anecdotally, too + // many parallel queries causes failures. + .mapParallel(parallelism = 4) { uri -> + val metadata = uriMetadataReader.getMetadata(uri) + PreviewModel( + uri = uri, + previewUri = metadata.previewUri, + mimeType = metadata.mimeType, + aspectRatio = + metadata.previewUri?.let { + uriMetadataReader.readPreviewSize(it).aspectRatioOrDefault(1f) + } + ?: 1f, + ) + } + .toSet() +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt new file mode 100644 index 00000000..c202eabf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback +import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest + +/** Communicates with the sharing application to notify of changes to the target intent. */ +class ProcessTargetIntentUpdatesInteractor +@Inject +constructor( + private val selectionCallback: SelectionChangeCallback, + private val repository: PendingSelectionCallbackRepository, + private val chooserRequestInteractor: UpdateChooserRequestInteractor, +) { + /** Listen for events and update state. */ + suspend fun activate() { + repository.pendingTargetIntent.collectLatest { targetIntent -> + targetIntent ?: return@collectLatest + selectionCallback.onSelectionChanged(targetIntent)?.let { update -> + chooserRequestInteractor.applyUpdate(targetIntent, update) + } + repository.pendingTargetIntent.compareAndSet(targetIntent, null) + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt new file mode 100644 index 00000000..55a995f5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** An individual preview in Shareousel. */ +class SelectablePreviewInteractor( + private val key: PreviewModel, + private val selectionInteractor: SelectionInteractor, +) { + val uri: Uri = key.uri + + /** Whether or not this preview is selected by the user. */ + val isSelected: Flow<Boolean> = selectionInteractor.selections.map { key in it } + + /** Sets whether this preview is selected by the user. */ + fun setSelected(isSelected: Boolean) { + if (isSelected) { + selectionInteractor.select(key) + } else { + selectionInteractor.unselect(key) + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt new file mode 100644 index 00000000..a578d0e2 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class SelectablePreviewsInteractor +@Inject +constructor( + private val previewsRepo: CursorPreviewsRepository, + private val selectionInteractor: SelectionInteractor, +) { + /** Keys of previews available for display in Shareousel. */ + val previews: Flow<PreviewsModel?> + get() = previewsRepo.previewsModel + + /** + * Returns a [SelectablePreviewInteractor] that can be used to interact with the individual + * preview associated with [key]. + */ + fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt new file mode 100644 index 00000000..e99aa50c --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.MimeTypeClassifier +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.updateAndGet + +class SelectionInteractor +@Inject +constructor( + private val selectionsRepo: PreviewSelectionsRepository, + private val targetIntentModifier: TargetIntentModifier<PreviewModel>, + private val updateTargetIntentInteractor: UpdateTargetIntentInteractor, + private val mimeTypeClassifier: MimeTypeClassifier, +) { + /** Set of selected previews. */ + val selections: StateFlow<Set<PreviewModel>> + get() = selectionsRepo.selections + + /** Amount of selected previews. */ + val amountSelected: Flow<Int> = selectionsRepo.selections.map { it.size } + + val aggregateContentType: Flow<ContentType> = selections.map { aggregateContentType(it) } + + fun select(model: PreviewModel) { + updateChooserRequest(selectionsRepo.selections.updateAndGet { it + model }) + } + + fun unselect(model: PreviewModel) { + if (selectionsRepo.selections.value.size > 1) { + updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model }) + } + } + + private fun updateChooserRequest(selections: Set<PreviewModel>) { + val intent = targetIntentModifier.intentFromSelection(selections) + updateTargetIntentInteractor.updateTargetIntent(intent) + } + + private fun aggregateContentType( + items: Set<PreviewModel>, + ): ContentType { + if (items.isEmpty()) { + return ContentType.Other + } + + var allImages = true + var allVideos = true + for (item in items) { + allImages = allImages && mimeTypeClassifier.isImageType(item.mimeType) + allVideos = allVideos && mimeTypeClassifier.isVideoType(item.mimeType) + + if (!allImages && !allVideos) { + break + } + } + + return when { + allImages -> ContentType.Image + allVideos -> ContentType.Video + else -> ContentType.Other + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt new file mode 100644 index 00000000..21a599fa --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Updates [CursorPreviewsRepository] with new previews. */ +class SetCursorPreviewsInteractor +@Inject +constructor(private val previewsRepo: CursorPreviewsRepository) { + /** Stores new [previewsByKey], and returns a flow of load requests triggered by Shareousel. */ + fun setPreviews( + previewsByKey: Set<PreviewModel>, + startIndex: Int, + hasMoreLeft: Boolean, + hasMoreRight: Boolean, + ): Flow<LoadDirection?> { + val loadingState = MutableStateFlow<LoadDirection?>(null) + previewsRepo.previewsModel.value = + PreviewsModel( + previewModels = previewsByKey, + startIdx = startIndex, + loadMoreLeft = + if (hasMoreLeft) { + ({ loadingState.value = LoadDirection.Left }) + } else { + null + }, + loadMoreRight = + if (hasMoreRight) { + ({ loadingState.value = LoadDirection.Right }) + } else { + null + }, + ) + return loadingState.asStateFlow() + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt new file mode 100644 index 00000000..4cf10414 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.util.Size + +internal fun Size?.aspectRatioOrDefault(default: Float): Float = + when { + this == null -> default + width >= 0 && height > 0 -> width.toFloat() / height.toFloat() + else -> default + } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt new file mode 100644 index 00000000..dd16f0c1 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.CustomAction +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.onValue +import com.android.intentresolver.data.repository.ChooserRequestRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.update + +/** Updates the tracked chooser request. */ +class UpdateChooserRequestInteractor +@Inject +constructor( + private val repository: ChooserRequestRepository, + @CustomAction private val pendingIntentSender: PendingIntentSender, +) { + fun applyUpdate(targetIntent: Intent, update: ShareouselUpdate) { + repository.chooserRequest.update { current -> + current.copy( + targetIntent = targetIntent, + callerChooserTargets = + update.callerTargets.getOrDefault(current.callerChooserTargets), + modifyShareAction = + update.modifyShareAction.getOrDefault(current.modifyShareAction), + additionalTargets = update.alternateIntents.getOrDefault(current.additionalTargets), + chosenComponentSender = + update.resultIntentSender.getOrDefault(current.chosenComponentSender), + refinementIntentSender = + update.refinementIntentSender.getOrDefault(current.refinementIntentSender), + metadataText = update.metadataText.getOrDefault(current.metadataText), + chooserActions = update.customActions.getOrDefault(current.chooserActions), + ) + } + update.customActions.onValue { actions -> + repository.customActions.value = + actions.map { it.toCustomActionModel(pendingIntentSender) } + } + } + + fun setTargetIntent(targetIntent: Intent) { + repository.chooserRequest.update { it.copy(targetIntent = targetIntent) } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt new file mode 100644 index 00000000..d99d69ab --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import javax.inject.Inject + +class UpdateTargetIntentInteractor +@Inject +constructor( + private val repository: PendingSelectionCallbackRepository, + private val chooserRequestInteractor: UpdateChooserRequestInteractor, +) { + /** + * Updates the target intent for the chooser. This will kick off an asynchronous IPC with the + * sharing application, so that it can react to the new intent. + */ + fun updateTargetIntent(targetIntent: Intent) { + repository.pendingTargetIntent.value = targetIntent + chooserRequestInteractor.setTargetIntent(targetIntent) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt new file mode 100644 index 00000000..f69365d7 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +import com.android.intentresolver.icon.ComposeIcon + +/** An action that the user can take, provided by the sharing application. */ +data class ActionModel( + /** Text shown for this action in the UI. */ + val label: CharSequence, + /** An optional [ComposeIcon] that will be displayed in the UI with this action. */ + val icon: ComposeIcon?, + /** + * Performs the action. The argument indicates the index in the UI that this action is shown. + */ + val performAction: (index: Int) -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt new file mode 100644 index 00000000..f1d856ac --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +import android.net.Uri +import android.util.Size + +/** Represents additional content cursor row */ +data class CursorRow(val uri: Uri, val previewSize: Size?) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt new file mode 100644 index 00000000..23510f15 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +/** Specifies which side of the dataset is being loaded. */ +enum class LoadDirection { + Left, + Right, +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt new file mode 100644 index 00000000..e2e69852 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +/** A window of data loaded from a cursor. */ +data class LoadedWindow<K, V>( + /** First cursor page index loaded within this window. */ + val firstLoadedPageNum: Int, + /** Last cursor page index loaded within this window. */ + val lastLoadedPageNum: Int, + /** Keys of cursor data within this window, grouped by loaded page. */ + val pages: List<Set<K>>, + /** Merged set of all cursor data within this window. */ + val merged: Map<K, V>, + /** Is there more data to the left of this window? */ + val hasMoreLeft: Boolean, + /** Is there more data to the right of this window? */ + val hasMoreRight: Boolean, +) + +/** Number of loaded pages stored within this [LoadedWindow]. */ +val LoadedWindow<*, *>.numLoadedPages: Int + get() = (lastLoadedPageNum - firstLoadedPageNum) + 1 + +/** Inserts [newPage] to the right, and removes the leftmost page from the window. */ +fun <K, V> LoadedWindow<K, V>.shiftWindowRight( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum + 1, + lastLoadedPageNum = lastLoadedPageNum + 1, + pages = pages.drop(1) + listOf(newPage.keys), + merged = + buildMap { + putAll(merged) + pages.first().forEach(::remove) + putAll(newPage) + }, + hasMoreLeft = true, + hasMoreRight = hasMore, + ) + +/** Inserts [newPage] to the right, increasing the size of the window to accommodate it. */ +fun <K, V> LoadedWindow<K, V>.expandWindowRight( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum, + lastLoadedPageNum = lastLoadedPageNum + 1, + pages = pages + listOf(newPage.keys), + merged = merged + newPage, + hasMoreLeft = hasMoreLeft, + hasMoreRight = hasMore, + ) + +/** Inserts [newPage] to the left, and removes the rightmost page from the window. */ +fun <K, V> LoadedWindow<K, V>.shiftWindowLeft( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum - 1, + lastLoadedPageNum = lastLoadedPageNum - 1, + pages = listOf(newPage.keys) + pages.dropLast(1), + merged = + buildMap { + putAll(newPage) + putAll(merged - pages.last()) + }, + hasMoreLeft = hasMore, + hasMoreRight = true, + ) + +/** Inserts [newPage] to the left, increasing the size olf the window to accommodate it. */ +fun <K, V> LoadedWindow<K, V>.expandWindowLeft( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum - 1, + lastLoadedPageNum = lastLoadedPageNum, + pages = listOf(newPage.keys) + pages, + merged = newPage + merged, + hasMoreLeft = hasMore, + hasMoreRight = hasMoreRight, + ) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt new file mode 100644 index 00000000..821e88a5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +import android.content.Intent +import android.content.IntentSender +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget + +/** Sharing session updates provided by the sharing app from the payload change callback */ +data class ShareouselUpdate( + // for all properties, null value means no change + val customActions: ValueUpdate<List<ChooserAction>> = ValueUpdate.Absent, + val modifyShareAction: ValueUpdate<ChooserAction?> = ValueUpdate.Absent, + val alternateIntents: ValueUpdate<List<Intent>> = ValueUpdate.Absent, + val callerTargets: ValueUpdate<List<ChooserTarget>> = ValueUpdate.Absent, + val refinementIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent, + val resultIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent, + val metadataText: ValueUpdate<CharSequence?> = ValueUpdate.Absent, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt new file mode 100644 index 00000000..bad4eebe --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +/** Represents an either updated value or the absence of it */ +sealed interface ValueUpdate<out T> { + data class Value<T>(val value: T) : ValueUpdate<T> + data object Absent : ValueUpdate<Nothing> +} + +/** Return encapsulated value if this instance represent Value or `default` if Absent */ +fun <T> ValueUpdate<T>.getOrDefault(default: T): T = + when (this) { + is ValueUpdate.Value -> value + is ValueUpdate.Absent -> default + } + +/** Executes the `block` with encapsulated value if this instance represents Value */ +inline fun <T> ValueUpdate<T>.onValue(block: (T) -> Unit) { + if (this is ValueUpdate.Value) { + block(value) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt new file mode 100644 index 00000000..1d34dc75 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.update + +import android.content.ContentInterface +import android.content.Intent +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate +import com.android.intentresolver.inject.AdditionalContent +import com.android.intentresolver.inject.ChooserIntent +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.ui.viewmodel.readAlternateIntents +import com.android.intentresolver.ui.viewmodel.readChooserActions +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.log +import com.android.intentresolver.validation.types.array +import com.android.intentresolver.validation.types.value +import com.android.intentresolver.validation.validateFrom +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Inject +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val TAG = "SelectionChangeCallback" + +/** + * Encapsulates payload change callback invocation to the sharing app; handles callback arguments + * and result format mapping. + */ +fun interface SelectionChangeCallback { + suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? +} + +class SelectionChangeCallbackImpl +@Inject +constructor( + @AdditionalContent private val uri: Uri, + @ChooserIntent private val chooserIntent: Intent, + private val contentResolver: ContentInterface, + private val flags: ChooserServiceFlags, +) : SelectionChangeCallback { + private val mutex = Mutex() + + override suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? = + mutex + .withLock { + contentResolver.call( + requireNotNull(uri.authority) { "URI authority can not be null" }, + ON_SELECTION_CHANGED, + uri.toString(), + Bundle().apply { + putParcelable( + EXTRA_INTENT, + Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) } + ) + } + ) + } + ?.let { bundle -> + return when (val result = readCallbackResponse(bundle, flags)) { + is Valid -> { + result.warnings.forEach { it.log(TAG) } + result.value + } + is Invalid -> { + result.errors.forEach { it.log(TAG) } + null + } + } + } +} + +private fun readCallbackResponse( + bundle: Bundle, + flags: ChooserServiceFlags +): ValidationResult<ShareouselUpdate> { + return validateFrom(bundle::get) { + // An error is treated as an empty collection or null as the presence of a value indicates + // an intention to change the old value implying that the old value is obsolete (and should + // not be used). + val customActions = + bundle.readValueUpdate(EXTRA_CHOOSER_CUSTOM_ACTIONS) { + readChooserActions() ?: emptyList() + } + val modifyShareAction = + bundle.readValueUpdate(EXTRA_CHOOSER_MODIFY_SHARE_ACTION) { key -> + optional(value<ChooserAction>(key)) + } + val alternateIntents = + bundle.readValueUpdate(EXTRA_ALTERNATE_INTENTS) { + readAlternateIntents() ?: emptyList() + } + val callerTargets = + bundle.readValueUpdate(EXTRA_CHOOSER_TARGETS) { key -> + optional(array<ChooserTarget>(key)) ?: emptyList() + } + val refinementIntentSender = + bundle.readValueUpdate(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER) { key -> + optional(value<IntentSender>(key)) + } + val resultIntentSender = + bundle.readValueUpdate(EXTRA_CHOOSER_RESULT_INTENT_SENDER) { key -> + optional(value<IntentSender>(key)) + } + val metadataText = + if (flags.enableSharesheetMetadataExtra()) { + bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key -> + optional(value<CharSequence>(key)) + } + } else { + ValueUpdate.Absent + } + + ShareouselUpdate( + customActions, + modifyShareAction, + alternateIntents, + callerTargets, + refinementIntentSender, + resultIntentSender, + metadataText, + ) + } +} + +private inline fun <reified T> Bundle.readValueUpdate( + key: String, + block: (String) -> T +): ValueUpdate<T> = + if (containsKey(key)) { + ValueUpdate.Value(block(key)) + } else { + ValueUpdate.Absent + } + +@Module +@InstallIn(ViewModelComponent::class) +interface SelectionChangeCallbackModule { + @Binds fun bind(impl: SelectionChangeCallbackImpl): SelectionChangeCallback +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt new file mode 100644 index 00000000..3ef6d98f --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.shared + +/** Type of the content being previewed. */ +enum class ContentType { + Image, + Video, + Other +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt new file mode 100644 index 00000000..85c70004 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.shared.model + +import android.net.Uri + +/** An individual preview presented in Shareousel. */ +data class PreviewModel( + /** Uri for this item; if this preview is selected, this will be shared with the target app. */ + val uri: Uri, + /** Uri for the preview image. */ + val previewUri: Uri? = uri, + /** Mimetype for the data [uri] points to. */ + val mimeType: String?, + val aspectRatio: Float = 1f, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt new file mode 100644 index 00000000..0ac99bd3 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.shared.model + +/** A dataset of previews for Shareousel. */ +data class PreviewsModel( + /** All available [PreviewModel]s. */ + val previewModels: Set<PreviewModel>, + /** Index into [previewModels] that should be initially displayed to the user. */ + val startIdx: Int, + /** + * Signals that more data should be loaded to the left of this dataset. A `null` value indicates + * that there is no more data to load in that direction. + */ + val loadMoreLeft: (() -> Unit)?, + /** + * Signals that more data should be loaded to the right of this dataset. A `null` value + * indicates that there is no more data to load in that direction. + */ + val loadMoreRight: (() -> Unit)?, +) diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt index 87fb7618..8cf237da 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.contentpreview.shareousel.ui.composable +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import android.content.Context import android.content.ContextWrapper @@ -21,6 +21,8 @@ import android.content.res.Resources import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -30,10 +32,16 @@ import com.android.intentresolver.icon.ComposeIcon import com.android.intentresolver.icon.ResourceIcon @Composable -fun Image(icon: ComposeIcon) { +fun Image(icon: ComposeIcon, modifier: Modifier = Modifier, colorFilter: ColorFilter? = null) { when (icon) { - is AdaptiveIcon -> Image(icon.wrapped) - is BitmapIcon -> Image(icon.bitmap.asImageBitmap(), contentDescription = null) + is AdaptiveIcon -> Image(icon.wrapped, modifier, colorFilter = colorFilter) + is BitmapIcon -> + Image( + icon.bitmap.asImageBitmap(), + contentDescription = null, + modifier = modifier, + colorFilter = colorFilter + ) is ResourceIcon -> { val localContext = LocalContext.current val wrappedContext: Context = @@ -41,7 +49,12 @@ fun Image(icon: ComposeIcon) { override fun getResources(): Resources = icon.res } CompositionLocalProvider(LocalContext provides wrappedContext) { - Image(painterResource(icon.resId), contentDescription = null) + Image( + painterResource(icon.resId), + contentDescription = null, + modifier = modifier, + colorFilter = colorFilter + ) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index dc96e3c1..a0be1a9b 100644 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.contentpreview.shareousel.ui.composable +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -33,10 +33,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType @Composable fun ShareouselCard( image: @Composable () -> Unit, + contentType: ContentType, selected: Boolean, modifier: Modifier = Modifier, ) { @@ -45,7 +47,9 @@ fun ShareouselCard( val topButtonPadding = 12.dp Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) - AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + if (contentType == ContentType.Video) { + AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + } } } } @@ -54,7 +58,7 @@ fun ShareouselCard( private fun AnimationIcon(modifier: Modifier = Modifier) { Icon( painterResource(id = R.drawable.ic_play_circle_filled_24px), - "animating", + contentDescription = null, // Video attribute described at a higher level. tint = Color.White, modifier = Modifier.size(20.dp).then(modifier) ) @@ -66,8 +70,8 @@ private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { val bgColor = MaterialTheme.colorScheme.primary Icon( painter = painterResource(id = R.drawable.checkbox), - tint = Color.White, - contentDescription = "selected", + tint = MaterialTheme.colorScheme.onPrimary, + contentDescription = null, modifier = Modifier.shadow( elevation = 50.dp, @@ -88,10 +92,14 @@ private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { spotColor = Color(0x40000000), ambientColor = Color(0x40000000), ) - .border(width = 2.dp, color = Color(0xFFFFFFFF), shape = CircleShape) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + shape = CircleShape + ) .clip(CircleShape) .size(20.dp) - .background(color = Color(0x7DC4C4C4)) + .background(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f)) .then(modifier) ) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt new file mode 100644 index 00000000..c25b0154 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import kotlinx.coroutines.launch + +@Composable +fun Shareousel(viewModel: ShareouselViewModel) { + val keySet = viewModel.previews.collectAsStateWithLifecycle(null).value + if (keySet != null) { + Shareousel(viewModel, keySet) + } else { + Spacer( + Modifier.height(dimensionResource(R.dimen.chooser_preview_image_height_tall) + 64.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) + } +} + +@Composable +private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) { + Column( + modifier = + Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + .padding(vertical = 16.dp), + ) { + PreviewCarousel(keySet, viewModel) + ActionCarousel(viewModel) + } +} + +@Composable +private fun PreviewCarousel( + previews: PreviewsModel, + viewModel: ShareouselViewModel, +) { + val centerIdx = previews.startIdx + val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) + // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if + // HorizontalPager works for our use-case + LazyRow( + state = carouselState, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier.fillMaxWidth() + .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) + .systemGestureExclusion() + ) { + items(previews.previewModels.toList(), key = { it.uri }) { model -> + ShareouselCard(viewModel.preview(model)) + } + } +} + +@Composable +private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { + val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) + val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) + val borderColor = MaterialTheme.colorScheme.primary + val scope = rememberCoroutineScope() + val contentDescription = + when (viewModel.contentType) { + ContentType.Image -> stringResource(R.string.selectable_image) + ContentType.Video -> stringResource(R.string.selectable_video) + else -> stringResource(R.string.selectable_item) + } + ShareouselCard( + image = { + // TODO: max ratio is actually equal to the viewport ratio + val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + bitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(aspectRatio), + ) + } + ?: run { + // TODO: look at ScrollableImagePreviewView.setLoading() + Box(modifier = Modifier.fillMaxHeight().aspectRatio(aspectRatio)) + } + }, + contentType = viewModel.contentType, + selected = selected, + modifier = + Modifier.thenIf(selected) { + Modifier.border( + width = 4.dp, + color = borderColor, + shape = RoundedCornerShape(size = 12.dp), + ) + } + .semantics { this.contentDescription = contentDescription } + .clip(RoundedCornerShape(size = 12.dp)) + .toggleable( + value = selected, + onValueChange = { scope.launch { viewModel.setSelected(it) } }, + ) + ) +} + +@Composable +private fun ActionCarousel(viewModel: ShareouselViewModel) { + val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) + if (actions.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.height(32.dp), + ) { + itemsIndexed(actions) { idx, actionViewModel -> + if (idx == 0) { + Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) + } + ShareouselAction( + label = actionViewModel.label, + onClick = { actionViewModel.onClicked() }, + ) { + actionViewModel.icon?.let { + Image( + icon = it, + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ) + } + } + if (idx == actions.size - 1) { + Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) + } + } + } + } +} + +@Composable +private fun ShareouselAction( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, +) { + AssistChip( + onClick = onClick, + label = { Text(label) }, + leadingIcon = leadingIcon, + border = null, + shape = RoundedCornerShape(1000.dp), // pill shape. + colors = + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + labelColor = MaterialTheme.colorScheme.onSurface, + leadingIconContentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = modifier, + ) +} + +inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = + if (condition) this.then(factory()) else this + +private const val MIN_ASPECT_RATIO = 0.4f +private const val MAX_ASPECT_RATIO = 2.5f diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt new file mode 100644 index 00000000..728c573b --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import com.android.intentresolver.icon.ComposeIcon + +/** An action chip presented to the user underneath Shareousel. */ +data class ActionChipViewModel( + /** Text label. */ + val label: String, + /** Optional icon, displayed next to the text label. */ + val icon: ComposeIcon?, + /** Handles user clicks on this action in the UI. */ + val onClicked: () -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt new file mode 100644 index 00000000..540229c9 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import android.graphics.Bitmap +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import kotlinx.coroutines.flow.Flow + +/** An individual preview within Shareousel. */ +data class ShareouselPreviewViewModel( + /** Image to be shared. */ + val bitmap: Flow<Bitmap?>, + /** Type of data to be shared. */ + val contentType: ContentType, + /** Whether this preview has been selected by the user. */ + val isSelected: Flow<Boolean>, + /** Sets whether this preview has been selected by the user. */ + val setSelected: suspend (Boolean) -> Unit, + val aspectRatio: Float, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt new file mode 100644 index 00000000..4eda3fa9 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader +import com.android.intentresolver.contentpreview.HeadlineGenerator +import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.MimeTypeClassifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.inject.ViewModelOwned +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.zip + +/** A dynamic carousel of selectable previews within share sheet. */ +data class ShareouselViewModel( + /** Text displayed at the top of the share sheet when Shareousel is present. */ + val headline: Flow<String>, + /** App-provided text shown beneath the headline. */ + val metadataText: Flow<CharSequence?>, + /** + * Previews which are available for presentation within Shareousel. Use [preview] to create a + * [ShareouselPreviewViewModel] for a given [PreviewModel]. + */ + val previews: Flow<PreviewsModel?>, + /** List of action chips presented underneath Shareousel. */ + val actions: Flow<List<ActionChipViewModel>>, + /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ + val preview: (key: PreviewModel) -> ShareouselPreviewViewModel, +) + +@Module +@InstallIn(ViewModelComponent::class) +interface ShareouselViewModelModule { + + @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader + + companion object { + @Provides + fun create( + interactor: SelectablePreviewsInteractor, + @PayloadToggle imageLoader: ImageLoader, + actionsInteractor: CustomActionsInteractor, + headlineGenerator: HeadlineGenerator, + selectionInteractor: SelectionInteractor, + chooserRequestInteractor: ChooserRequestInteractor, + mimeTypeClassifier: MimeTypeClassifier, + // TODO: remove if possible + @ViewModelOwned scope: CoroutineScope, + ): ShareouselViewModel { + val keySet = + interactor.previews.stateIn( + scope, + SharingStarted.Eagerly, + initialValue = null, + ) + return ShareouselViewModel( + headline = + selectionInteractor.aggregateContentType.zip( + selectionInteractor.amountSelected + ) { contentType, numItems -> + when (contentType) { + ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) + ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) + ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) + } + }, + metadataText = chooserRequestInteractor.metadataText, + previews = keySet, + actions = + actionsInteractor.customActions.map { actions -> + actions.mapIndexedNotNull { i, model -> + val icon = model.icon + val label = model.label + if (icon == null && label.isBlank()) { + null + } else { + ActionChipViewModel( + label = label.toString(), + icon = model.icon, + onClicked = { model.performAction(i) }, + ) + } + } + }, + preview = { key -> + keySet.value?.maybeLoad(key) + val previewInteractor = interactor.preview(key) + val contentType = + when { + mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image + mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video + else -> ContentType.Other + } + ShareouselPreviewViewModel( + bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, + contentType = contentType, + isSelected = previewInteractor.isSelected, + setSelected = previewInteractor::setSelected, + aspectRatio = key.aspectRatio, + ) + }, + ) + } + } +} + +private fun PreviewsModel.maybeLoad(key: PreviewModel) { + when (key) { + previewModels.firstOrNull() -> loadMoreLeft?.invoke() + previewModels.lastOrNull() -> loadMoreRight?.invoke() + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt deleted file mode 100644 index 5cf35297..00000000 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.contentpreview.shareousel.ui.composable - -import androidx.compose.foundation.Image -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AssistChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.android.intentresolver.R -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselImageViewModel -import com.android.intentresolver.contentpreview.shareousel.ui.viewmodel.ShareouselViewModel - -@Composable -fun Shareousel(viewModel: ShareouselViewModel) { - val centerIdx = viewModel.centerIndex.value - val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) - val previewKeys by viewModel.previewKeys.collectAsStateWithLifecycle() - Column { - // TODO: item needs to be centered, check out ScalingLazyColumn impl or see if - // HorizontalPager works for our use-case - LazyRow( - state = carouselState, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier.fillMaxWidth() - .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) - ) { - items(previewKeys, key = viewModel.previewRowKey) { key -> - ShareouselCard(viewModel.previewForKey(key)) - } - } - Spacer(modifier = Modifier.height(8.dp)) - - val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(actions) { actionViewModel -> - ShareouselAction( - label = actionViewModel.label, - onClick = actionViewModel.onClick, - ) { - actionViewModel.icon?.let { Image(it) } - } - } - } - } -} - -private const val MIN_ASPECT_RATIO = 0.4f -private const val MAX_ASPECT_RATIO = 2.5f - -@Composable -private fun ShareouselCard(viewModel: ShareouselImageViewModel) { - val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) - val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) - val contentDescription by - viewModel.contentDescription.collectAsStateWithLifecycle(initialValue = null) - val borderColor = MaterialTheme.colorScheme.primary - - ShareouselCard( - image = { - bitmap?.let { bitmap -> - val aspectRatio = - (bitmap.width.toFloat() / bitmap.height.toFloat()) - // TODO: max ratio is actually equal to the viewport ratio - .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = contentDescription, - contentScale = ContentScale.Crop, - modifier = Modifier.aspectRatio(aspectRatio), - ) - } - ?: run { - // TODO: look at ScrollableImagePreviewView.setLoading() - Box(modifier = Modifier.aspectRatio(2f / 5f)) - } - }, - selected = selected, - modifier = - Modifier.thenIf(selected) { - Modifier.border( - width = 4.dp, - color = borderColor, - shape = RoundedCornerShape(size = 12.dp) - ) - } - .clip(RoundedCornerShape(size = 12.dp)) - .clickable { viewModel.setSelected(!selected) }, - ) -} - -@Composable -private fun ShareouselAction( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - leadingIcon: (@Composable () -> Unit)? = null, -) { - AssistChip( - onClick = onClick, - label = { Text(label) }, - leadingIcon = leadingIcon, - modifier = modifier - ) -} - -inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = - if (condition) this.then(factory()) else this diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt deleted file mode 100644 index 18ee2539..00000000 --- a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.contentpreview.shareousel.ui.viewmodel - -import android.graphics.Bitmap -import androidx.core.graphics.drawable.toBitmap -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.contentpreview.ImageLoader -import com.android.intentresolver.contentpreview.MutableActionFactory -import com.android.intentresolver.contentpreview.PayloadToggleInteractor -import com.android.intentresolver.icon.BitmapIcon -import com.android.intentresolver.icon.ComposeIcon -import com.android.intentresolver.widget.ActionRow.Action -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn - -data class ShareouselViewModel( - val headline: Flow<String>, - val previewKeys: StateFlow<List<Any>>, - val actions: Flow<List<ActionChipViewModel>>, - val centerIndex: StateFlow<Int>, - val previewForKey: (key: Any) -> ShareouselImageViewModel, - val previewRowKey: (Any) -> Any -) - -data class ActionChipViewModel(val label: String, val icon: ComposeIcon?, val onClick: () -> Unit) - -data class ShareouselImageViewModel( - val bitmap: Flow<Bitmap?>, - val contentDescription: Flow<String>, - val isSelected: Flow<Boolean>, - val setSelected: (Boolean) -> Unit, -) - -suspend fun PayloadToggleInteractor.toShareouselViewModel( - imageLoader: ImageLoader, - actionFactory: ActionFactory, - scope: CoroutineScope, -): ShareouselViewModel { - return ShareouselViewModel( - headline = MutableStateFlow("Shareousel"), - previewKeys = previewKeys.stateIn(scope), - actions = - if (actionFactory is MutableActionFactory) { - actionFactory.customActionsFlow.map { actions -> - actions.map { it.toActionChipViewModel() } - } - } else { - flow { - emit(actionFactory.createCustomActions().map { it.toActionChipViewModel() }) - } - }, - centerIndex = targetPosition.stateIn(scope), - previewForKey = { key -> - val previewInteractor = previewInteractor(key) - ShareouselImageViewModel( - bitmap = previewInteractor.previewUri.map { uri -> uri?.let { imageLoader(uri) } }, - contentDescription = MutableStateFlow(""), - isSelected = previewInteractor.selected, - setSelected = { isSelected -> previewInteractor.setSelected(isSelected) }, - ) - }, - previewRowKey = { getKey(it) }, - ) -} - -private fun Action.toActionChipViewModel() = - ActionChipViewModel( - label?.toString() ?: "", - icon?.let { BitmapIcon(it.toBitmap()) }, - onClick = { onClicked.run() } - ) diff --git a/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt b/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt new file mode 100644 index 00000000..cf31ea10 --- /dev/null +++ b/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Handler +import android.os.UserHandle +import android.util.Log +import com.android.intentresolver.inject.Broadcast +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +private const val TAG = "BroadcastSubscriber" + +class BroadcastSubscriber +@Inject +constructor( + @ApplicationContext private val context: Context, + @Broadcast private val handler: Handler +) { + /** + * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new + * value whenever broadcast matching _filter_ is received. The result value will be computed + * using [transform] and emitted if non-null. + */ + fun <T> createFlow( + filter: IntentFilter, + user: UserHandle, + transform: (Intent) -> T?, + ): Flow<T> = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + transform(intent)?.also { result -> + trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } + } + ?: Log.w(TAG, "Ignored broadcast $intent") + } + } + + @Suppress("MissingPermission") + context.registerReceiverAsUser( + receiver, + user, + IntentFilter(filter), + null, + handler, + Context.RECEIVER_NOT_EXPORTED + ) + awaitClose { context.unregisterReceiver(receiver) } + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt index 4f3cf3cd..045a17f6 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt +++ b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.ui.model +package com.android.intentresolver.data.model import android.content.ComponentName import android.content.Intent @@ -28,7 +28,7 @@ import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget import androidx.annotation.StringRes import com.android.intentresolver.ContentTypeHint -import com.android.intentresolver.v2.ext.hasAction +import com.android.intentresolver.ext.hasAction const val ANDROID_APP_SCHEME = "android-app" @@ -38,17 +38,17 @@ data class ChooserRequest( val targetIntent: Intent, /** The action from [targetIntent] as retrieved with [Intent.getAction]. */ - val targetAction: String?, + val targetAction: String? = targetIntent.action, /** * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the * canonical "Share" actions. When handling other actions, this flag controls behavioral and * visual changes. */ - val isSendActionTarget: Boolean, + val isSendActionTarget: Boolean = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), /** The top-level content type as retrieved using [Intent.getType]. */ - val targetType: String?, + val targetType: String? = targetIntent.type, /** The package name of the app which started the current activity instance. */ val launchedFromPackage: String, @@ -63,7 +63,7 @@ data class ChooserRequest( * The referrer value as received by the caller. It may have been supplied via [EXTRA_REFERRER] * or synthesized from callerPackageName. This value is merged into outgoing intents. */ - val referrer: Uri?, + val referrer: Uri? = null, /** * Choices to exclude from results. @@ -192,18 +192,4 @@ data class ChooserRequest( } val payloadIntents = listOf(targetIntent) + additionalTargets - - /** Constructs an instance from only the required values. */ - constructor( - targetIntent: Intent, - launchedFromPackage: String, - referrer: Uri? - ) : this( - targetIntent = targetIntent, - targetAction = targetIntent.action, - isSendActionTarget = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), - targetType = targetIntent.type, - launchedFromPackage = launchedFromPackage, - referrer = referrer - ) } diff --git a/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt new file mode 100644 index 00000000..14177b1b --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.data.model.ChooserRequest +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +@ViewModelScoped +class ChooserRequestRepository +@Inject +constructor( + initialRequest: ChooserRequest, + initialActions: List<CustomActionModel>, +) { + /** All information from the sharing application pertaining to the chooser. */ + val chooserRequest: MutableStateFlow<ChooserRequest> = MutableStateFlow(initialRequest) + + /** Custom actions from the sharing app to be presented in the chooser. */ + // NOTE: this could be derived directly from chooserRequest, but that would require working + // directly with PendingIntents, which complicates testing. + val customActions: MutableStateFlow<List<CustomActionModel>> = MutableStateFlow(initialActions) +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt index 5719ff08..eb35a358 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt +++ b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt @@ -13,24 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.data.repository +package com.android.intentresolver.data.repository import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY import android.content.res.Resources +import androidx.annotation.OpenForTesting import com.android.intentresolver.R import com.android.intentresolver.inject.ApplicationOwned import javax.inject.Inject import javax.inject.Singleton +@OpenForTesting @Singleton -class DevicePolicyResources +open class DevicePolicyResources @Inject constructor( @ApplicationOwned private val resources: Resources, @@ -83,6 +88,54 @@ constructor( ) } + val noPersonalApps by lazy { + requireNotNull( + policyResources.getString(RESOLVER_NO_PERSONAL_APPS) { + resources.getString(R.string.resolver_no_personal_apps_available) + } + ) + } + + val noWorkApps by lazy { + requireNotNull( + policyResources.getString(RESOLVER_NO_WORK_APPS) { + resources.getString(R.string.resolver_no_work_apps_available) + } + ) + } + + open val crossProfileBlocked by lazy { + requireNotNull( + policyResources.getString(RESOLVER_CROSS_PROFILE_BLOCKED_TITLE) { + resources.getString(R.string.resolver_cross_profile_blocked) + } + ) + } + + open fun toPersonalBlockedByPolicyMessage(share: Boolean): String { + return if (share) { + resources.getString(R.string.resolver_cant_share_with_personal_apps_explanation) + } else { + resources.getString(R.string.resolver_cant_access_personal_apps_explanation) + } + } + + open fun toWorkBlockedByPolicyMessage(share: Boolean): String { + return if (share) { + resources.getString(R.string.resolver_cant_share_with_work_apps_explanation) + } else { + resources.getString(R.string.resolver_cant_access_work_apps_explanation) + } + } + + open fun toPrivateBlockedByPolicyMessage(share: Boolean): String { + return if (share) { + resources.getString(R.string.resolver_cant_share_with_private_apps_explanation) + } else { + resources.getString(R.string.resolver_cant_access_private_apps_explanation) + } + } + fun getWorkProfileNotSupportedMessage(launcherName: String): String { return requireNotNull( policyResources.getString( diff --git a/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt new file mode 100644 index 00000000..753df93e --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.pm.UserInfo +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role + +/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */ +fun UserInfo.getSupportedUserRole(): Role? = + when { + isFull -> Role.PERSONAL + isManagedProfile -> Role.WORK + isCloneProfile -> Role.CLONE + isPrivateProfile -> Role.PRIVATE + else -> null + } + +/** + * Creates a [User], based on values from a [UserInfo]. + * + * ``` + * val users: List<User> = + * getEnabledProfiles(user).map(::toUser).filterNotNull() + * ``` + * + * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null + */ +fun UserInfo.toUser(): User? { + return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } +} diff --git a/java/src/com/android/intentresolver/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/data/repository/UserRepository.kt new file mode 100644 index 00000000..6b5ff4ba --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserRepository.kt @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.Intent +import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE +import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.Intent.ACTION_PROFILE_UNAVAILABLE +import android.content.Intent.EXTRA_QUIET_MODE +import android.content.Intent.EXTRA_USER +import android.content.IntentFilter +import android.content.pm.UserInfo +import android.os.Build +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.intentresolver.data.BroadcastSubscriber +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.Main +import com.android.intentresolver.inject.ProfileParent +import com.android.intentresolver.shared.model.User +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +interface UserRepository { + /** + * A [Flow] user profile groups. Each list contains the context user along with all members of + * the profile group. This includes the (Full) parent user, if the context user is a profile. + */ + val users: Flow<List<User>> + + /** + * A [Flow] of availability. Only profile users may become unavailable. + * + * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. + */ + val availability: Flow<Map<User, Boolean>> + + /** + * Request that availability be updated to the requested state. This currently includes toggling + * quiet mode as needed. This may involve additional background actions, such as starting or + * stopping a profile user (along with their many associated processes). + * + * If successful, the change will be applied after the call returns and can be observed using + * [UserRepository.availability] for the given user. + * + * No actions are taken if the user is already in requested state. + * + * @throws IllegalArgumentException if called for an unsupported user type + */ + suspend fun requestState(user: User, available: Boolean) +} + +private const val TAG = "UserRepository" + +/** The delay between entering the cached process state and entering the frozen cgroup */ +private val cachedProcessFreezeDelay: Duration = 10.seconds + +/** How long to continue listening for user state broadcasts while unsubscribed */ +private val stateFlowTimeout = cachedProcessFreezeDelay - 2.seconds + +/** How long to retain the previous user state after the state flow stops. */ +private val stateCacheTimeout = 2.seconds + +internal data class UserWithState(val user: User, val available: Boolean) + +internal typealias UserStates = List<UserWithState> + +internal val userBroadcastActions = + setOf( + ACTION_PROFILE_ADDED, + ACTION_PROFILE_REMOVED, + + // Quiet mode enabled/disabled for managed + // From: UserController.broadcastProfileAvailabilityChanges + // In response to setQuietModeEnabled + ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only + ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only + + // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile + // true' + ACTION_PROFILE_AVAILABLE, // quiet mode, + ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type + ) + +/** Tracks and publishes state for the parent user and associated profiles. */ +class UserRepositoryImpl +@VisibleForTesting +constructor( + private val profileParent: UserHandle, + private val userManager: UserManager, + /** A flow of events which represent user-state changes from [UserManager]. */ + private val userEvents: Flow<UserEvent>, + scope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher, +) : UserRepository { + @Inject + constructor( + @ProfileParent profileParent: UserHandle, + userManager: UserManager, + @Main scope: CoroutineScope, + @Background background: CoroutineDispatcher, + broadcastSubscriber: BroadcastSubscriber, + ) : this( + profileParent, + userManager, + userEvents = + broadcastSubscriber.createFlow( + createFilter(userBroadcastActions), + profileParent, + Intent::toUserEvent + ), + scope, + background, + ) + + private fun debugLog(msg: () -> String) { + if (Build.IS_USERDEBUG || Build.IS_ENG) { + Log.d(TAG, msg()) + } + } + + private fun errorLog(msg: String, caught: Throwable? = null) { + Log.e(TAG, msg, caught) + } + + /** + * An exception which indicates that an inconsistency exists between the user state map and the + * rest of the system. + */ + private class UserStateException( + override val message: String, + val event: UserEvent, + override val cause: Throwable? = null, + ) : RuntimeException("$message: event=$event", cause) + + private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) + private val usersWithState: Flow<UserStates> = + userEvents + .onStart { emit(Initialize) } + .onEach { debugLog { "userEvent: $it" } } + .runningFold(emptyList(), ::handleEvent) + .distinctUntilChanged() + .onEach { debugLog { "userStateList: $it" } } + .stateIn( + sharingScope, + started = + WhileSubscribed( + stopTimeoutMillis = stateFlowTimeout.inWholeMilliseconds, + replayExpirationMillis = 0 + /** Immediately on stop */ + ), + listOf() + ) + .filterNot { it.isEmpty() } + + private suspend fun handleEvent(users: UserStates, event: UserEvent): UserStates { + return try { + // Handle an action by performing some operation, then returning a new map + when (event) { + is Initialize -> createNewUserStates(profileParent) + is ProfileAdded -> handleProfileAdded(event, users) + is ProfileRemoved -> handleProfileRemoved(event, users) + is AvailabilityChange -> handleAvailability(event, users) + is UnknownEvent -> { + debugLog { "Unhandled event: $event)" } + users + } + } + } catch (e: UserStateException) { + errorLog("An error occurred handling an event: ${e.event}") + errorLog("Attempting to recover...", e) + createNewUserStates(profileParent) + } + } + + override val users: Flow<List<User>> = + usersWithState.map { userStates -> userStates.map { it.user } }.distinctUntilChanged() + + override val availability: Flow<Map<User, Boolean>> = + usersWithState + .map { list -> list.associate { it.user to it.available } } + .distinctUntilChanged() + + override suspend fun requestState(user: User, available: Boolean) { + return withContext(backgroundDispatcher) { + debugLog { "requestQuietModeEnabled: ${!available} for user $user" } + userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) + } + } + + private fun List<UserWithState>.update(handle: UserHandle, user: UserWithState) = + filter { it.user.id != handle.identifier } + user + + private fun handleAvailability(event: AvailabilityChange, current: UserStates): UserStates { + val userEntry = + current.firstOrNull { it.user.id == event.user.identifier } + ?: throw UserStateException("User was not present in the map", event) + return current.update(event.user, userEntry.copy(available = !event.quietMode)) + } + + private fun handleProfileRemoved(event: ProfileRemoved, current: UserStates): UserStates { + if (!current.any { it.user.id == event.user.identifier }) { + throw UserStateException("User was not present in the map", event) + } + return current.filter { it.user.id != event.user.identifier } + } + + private suspend fun handleProfileAdded(event: ProfileAdded, current: UserStates): UserStates { + val user = + try { + requireNotNull(readUser(event.user)) + } catch (e: Exception) { + throw UserStateException("Failed to read user from UserManager", event, e) + } + return current + UserWithState(user, true) + } + + private suspend fun createNewUserStates(user: UserHandle): UserStates { + val profiles = readProfileGroup(user) + return profiles.mapNotNull { userInfo -> + userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } + } + } + + private suspend fun readProfileGroup(member: UserHandle): List<UserInfo> { + return withContext(backgroundDispatcher) { + @Suppress("DEPRECATION") userManager.getEnabledProfiles(member.identifier) + } + .toList() + } + + /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ + private suspend fun readUser(user: UserHandle): User? { + val userInfo = + withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } + return userInfo?.let { info -> + info.getSupportedUserRole()?.let { role -> User(info.id, role) } + } + } +} + +/** A Model representing changes to profiles and availability */ +sealed interface UserEvent + +/** Used as a an initial value to trigger a fetch of all profile data. */ +data object Initialize : UserEvent + +/** A profile was added to the profile group. */ +data class ProfileAdded( + /** The handle for the added profile. */ + val user: UserHandle, +) : UserEvent + +/** A profile was removed from the profile group. */ +data class ProfileRemoved( + /** The handle for the removed profile. */ + val user: UserHandle, +) : UserEvent + +/** A profile has changed availability. */ +data class AvailabilityChange( + /** THe handle for the profile with availability change. */ + val user: UserHandle, + /** The new quietMode state. */ + val quietMode: Boolean = false, +) : UserEvent + +/** An unhandled event, logged and ignored. */ +data class UnknownEvent( + /** The broadcast intent action received */ + val action: String?, +) : UserEvent + +/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */ +internal fun Intent.toUserEvent(): UserEvent { + val action = action + val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java) + val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) + return when (action) { + ACTION_PROFILE_ADDED -> ProfileAdded(requireNotNull(user)) + ACTION_PROFILE_REMOVED -> ProfileRemoved(requireNotNull(user)) + ACTION_MANAGED_PROFILE_UNAVAILABLE, + ACTION_MANAGED_PROFILE_AVAILABLE, + ACTION_PROFILE_AVAILABLE, + ACTION_PROFILE_UNAVAILABLE -> + AvailabilityChange(requireNotNull(user), requireNotNull(quietMode)) + else -> UnknownEvent(action) + } +} + +internal fun createFilter(actions: Iterable<String>): IntentFilter { + return IntentFilter().apply { actions.forEach(::addAction) } +} + +internal fun UserInfo?.isAvailable(): Boolean { + return this?.isQuietModeEnabled != true +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt index a84342f4..7109d6d4 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt +++ b/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt @@ -1,4 +1,20 @@ -package com.android.intentresolver.v2.data.repository +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository import android.content.Context import android.os.UserHandle diff --git a/java/src/com/android/intentresolver/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/data/repository/UserScopedService.kt new file mode 100644 index 00000000..10a33eb1 --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserScopedService.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.Context +import android.os.UserHandle +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlin.reflect.KClass + +/** + * Provides instances of a [system service][Context.getSystemService] created with + * [the context of a specified user][Context.createContextAsUser]. + * + * Some services which have only `@UserHandleAware` APIs operate on the user id available from + * [Context.getUser], the context used to retrieve the service. This utility helps adapt a per-user + * API model to work in multi-user manner. + * + * Example usage: + * ``` + * @Provides + * fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService<UserManager> { + * return UserScopedServiceImpl(ctx, UserManager::class) + * } + * + * class MyUserHelper @Inject constructor( + * private val userMgr: UserScopedService<UserManager>, + * ) { + * fun isPrivateProfile(user: UserHandle): UserManager { + * return userMgr.forUser(user).isPrivateProfile() + * } + * } + * ``` + */ +fun interface UserScopedService<T> { + /** Create a service instance for the given user. */ + fun forUser(user: UserHandle): T +} + +class UserScopedServiceImpl<T : Any>( + @ApplicationContext private val context: Context, + private val serviceType: KClass<T>, +) : UserScopedService<T> { + override fun forUser(user: UserHandle): T { + val context = + if (context.user == user) { + context + } else { + context.createContextAsUser(user, 0) + } + return requireNotNull(context.getSystemService(serviceType.java)) + } +} diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt index 72b604c2..2392a48d 100644 --- a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt +++ b/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt @@ -14,15 +14,15 @@ * limitations under the License. */ -package com.android.intentresolver.v2.domain.interactor +package com.android.intentresolver.domain.interactor import android.os.UserHandle +import com.android.intentresolver.data.repository.UserRepository import com.android.intentresolver.inject.ApplicationUser -import com.android.intentresolver.v2.data.repository.UserRepository -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.shared.model.Profile.Type -import com.android.intentresolver.v2.shared.model.User -import com.android.intentresolver.v2.shared.model.User.Role +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.Profile.Type +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -71,9 +71,7 @@ constructor( */ val availability: Flow<Map<Profile, Boolean>> = combine(profiles, userRepository.availability) { profiles, availability -> - profiles.associateWith { - availability.getOrDefault(it.primary, false) - } + profiles.associateWith { availability.getOrDefault(it.primary, false) } } /** diff --git a/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt index 41422b66..05062a4b 100644 --- a/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt @@ -13,34 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.emptystate; +package com.android.intentresolver.emptystate -import android.annotation.Nullable; - -import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.ResolverListAdapter /** * 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 class CompositeEmptyStateProvider implements EmptyStateProvider { - - private final EmptyStateProvider[] mProviders; - - public CompositeEmptyStateProvider(EmptyStateProvider... providers) { - mProviders = providers; - } +class CompositeEmptyStateProvider( + private vararg val providers: EmptyStateProvider, +) : EmptyStateProvider { - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - for (EmptyStateProvider provider : mProviders) { - EmptyState emptyState = provider.getEmptyState(resolverListAdapter); - if (emptyState != null) { - return emptyState; - } - } - return null; + override fun getEmptyState(resolverListAdapter: ResolverListAdapter): EmptyState? { + return providers.firstNotNullOfOrNull { it.getEmptyState(resolverListAdapter) } } } diff --git a/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt b/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt new file mode 100644 index 00000000..ea1a03cc --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate + +class DefaultEmptyState : EmptyState { + override fun useDefaultEmptyView() = true +} diff --git a/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java new file mode 100644 index 00000000..1cbc6175 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.app.admin.DevicePolicyEventLogger; + +import androidx.annotation.Nullable; + +/** + * Empty state that gets strings from the device policy manager and tracks events into + * event logger of the device policy events. + */ +public class DevicePolicyBlockerEmptyState implements EmptyState { + private final String mTitle; + private final String mSubtitle; + private final int mEventId; + private final String mEventCategory; + + public DevicePolicyBlockerEmptyState( + String title, + String subtitle, + int devicePolicyEventId, + String devicePolicyEventCategory) { + mTitle = title; + mSubtitle = subtitle; + mEventId = devicePolicyEventId; + mEventCategory = devicePolicyEventCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public String getSubtitle() { + return mSubtitle; + } + + @Override + public void onEmptyStateShown() { + if (mEventId != -1) { + DevicePolicyEventLogger.createEvent(mEventId) + .setStrings(mEventCategory) + .write(); + } + } + + @Override + public boolean shouldSkipDataRebuild() { + return true; + } +} diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java index d7ef8c75..7524f343 100644 --- a/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java +++ b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java @@ -17,47 +17,120 @@ package com.android.intentresolver.emptystate; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import java.util.Optional; +import java.util.function.Supplier; /** * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by * some empty-state status. */ public class EmptyStateUiHelper { + private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier; private final View mEmptyStateView; + private final View mListView; + private final View mEmptyStateContainerView; + private final TextView mEmptyStateTitleView; + private final TextView mEmptyStateSubtitleView; + private final Button mEmptyStateButtonView; + private final View mEmptyStateProgressView; + private final View mEmptyStateEmptyView; - public EmptyStateUiHelper(ViewGroup rootView) { + public EmptyStateUiHelper( + ViewGroup rootView, + int listViewResourceId, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; mEmptyStateView = rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); + mListView = rootView.requireViewById(listViewResourceId); + mEmptyStateContainerView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_container); + mEmptyStateTitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_title); + mEmptyStateSubtitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_subtitle); + mEmptyStateButtonView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_button); + mEmptyStateProgressView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_progress); + mEmptyStateEmptyView = mEmptyStateView.requireViewById(com.android.internal.R.id.empty); } - public void resetViewVisibilities() { - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - .setVisibility(View.VISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) - .setVisibility(View.VISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) - .setVisibility(View.INVISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) - .setVisibility(View.GONE); - mEmptyStateView.requireViewById(com.android.internal.R.id.empty) - .setVisibility(View.GONE); - mEmptyStateView.setVisibility(View.VISIBLE); + /** + * Display the described empty state. + * @param emptyState the data describing the cause of this empty-state condition. + * @param buttonOnClick handler for a button that the user might be able to use to circumvent + * the empty-state condition. If null, no button will be displayed. + */ + public void showEmptyState(EmptyState emptyState, View.OnClickListener buttonOnClick) { + resetViewVisibilities(); + setupContainerPadding(); + + String title = emptyState.getTitle(); + if (title != null) { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateTitleView.setText(title); + } else { + mEmptyStateTitleView.setVisibility(View.GONE); + } + + String subtitle = emptyState.getSubtitle(); + if (subtitle != null) { + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setText(subtitle); + } else { + mEmptyStateSubtitleView.setVisibility(View.GONE); + } + + mEmptyStateEmptyView.setVisibility( + emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); + // TODO: The EmptyState API says that if `useDefaultEmptyView()` is true, we'll ignore the + // state's specified title/subtitle; where (if anywhere) is that implemented? + + mEmptyStateButtonView.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); + mEmptyStateButtonView.setOnClickListener(buttonOnClick); + + // Don't show the main list view when we're showing an empty state. + mListView.setVisibility(View.GONE); + } + + /** Sets up the padding of the view containing the empty state screens. */ + public void setupContainerPadding() { + Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); + bottomPaddingOverride.ifPresent(paddingBottom -> + mEmptyStateContainerView.setPadding( + mEmptyStateContainerView.getPaddingLeft(), + mEmptyStateContainerView.getPaddingTop(), + mEmptyStateContainerView.getPaddingRight(), + paddingBottom)); } public void showSpinner() { - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - .setVisibility(View.INVISIBLE); + mEmptyStateTitleView.setVisibility(View.INVISIBLE); // TODO: subtitle? - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_button) - .setVisibility(View.INVISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) - .setVisibility(View.VISIBLE); - mEmptyStateView.requireViewById(com.android.internal.R.id.empty) - .setVisibility(View.GONE); + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.VISIBLE); + mEmptyStateEmptyView.setVisibility(View.GONE); } public void hide() { mEmptyStateView.setVisibility(View.GONE); + mListView.setVisibility(View.VISIBLE); } -} + // TODO: this is exposed for testing so we can thoroughly prepare initial conditions that let us + // observe the resulting change. In reality it's only invoked as part of `showEmptyState()` and + // we could consider setting up narrower "realistic" preconditions to make assertions about the + // higher-level operation. + public void resetViewVisibilities() { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.GONE); + mEmptyStateEmptyView.setVisibility(View.GONE); + mEmptyStateView.setVisibility(View.VISIBLE); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java new file mode 100644 index 00000000..b03c730a --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.app.admin.DevicePolicyEventLogger; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import androidx.annotation.NonNull; + +public class NoAppsAvailableEmptyState implements EmptyState { + + @NonNull + private final String mTitle; + + @NonNull + private final String mMetricsCategory; + + private final boolean mIsPersonalProfile; + + public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory, + boolean isPersonalProfile) { + mTitle = title; + mMetricsCategory = metricsCategory; + mIsPersonalProfile = isPersonalProfile; + } + + @NonNull + @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(); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java index 5f10cf32..b3d3e343 100644 --- a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -16,24 +16,20 @@ package com.android.intentresolver.emptystate; -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.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.content.pm.ResolveInfo; +import static com.android.intentresolver.shared.model.Profile.Type.PERSONAL; + +import static java.util.Objects.requireNonNull; + import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolvedComponentInfo; +import com.android.intentresolver.ProfileAvailability; +import com.android.intentresolver.ProfileHelper; import com.android.intentresolver.ResolverListAdapter; - -import java.util.List; +import com.android.intentresolver.shared.model.Profile; +import com.android.intentresolver.ui.ProfilePagerResources; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when @@ -41,118 +37,37 @@ import java.util.List; */ 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 UserHandle mTabOwnerUserHandleForLaunch; + @NonNull private final String mMetricsCategory; + private final ProfilePagerResources mProfilePagerResources; + private final ProfileHelper mProfileHelper; + private final ProfileAvailability mProfileAvailability; public NoAppsAvailableEmptyStateProvider( - @NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @Nullable UserHandle personalProfileUserHandle, + ProfileHelper profileHelper, + ProfileAvailability profileAvailability, @NonNull String metricsCategory, - @NonNull UserHandle tabOwnerUserHandleForLaunch) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mPersonalProfileUserHandle = personalProfileUserHandle; + ProfilePagerResources profilePagerResources) { + mProfileHelper = profileHelper; + mProfileAvailability = profileAvailability; mMetricsCategory = metricsCategory; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; + mProfilePagerResources = profilePagerResources; } - @Nullable + @NonNull @Override - @SuppressWarnings("ReferenceEquality") public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { UserHandle listUserHandle = resolverListAdapter.getUserHandle(); - - if (mWorkProfileUserHandle != null - && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) - || !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)); - } - + if (mProfileAvailability.visibleProfileCount() == 1) { + return new DefaultEmptyState(); + } else { + Profile.Type profileType = + requireNonNull(mProfileHelper.findProfileType(listUserHandle)); + String title = mProfilePagerResources.noAppsMessage(profileType); return new NoAppsAvailableEmptyState( - title, mMetricsCategory, - /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle + title, + mMetricsCategory, + /* isPersonalProfile= */ profileType == PERSONAL ); - } 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<ResolvedComponentInfo> resolversForIntent = - adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); - for (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(@NonNull String title, - @NonNull 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(); } } } diff --git a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java index ce7bd8d9..0cf2ea45 100644 --- a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java @@ -16,124 +16,103 @@ package com.android.intentresolver.emptystate; -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.os.UserHandle; +import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; +import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; + +import static com.android.intentresolver.ChooserActivity.METRICS_CATEGORY_CHOOSER; + +import static java.util.Objects.requireNonNull; + +import android.content.Intent; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.StringRes; +import com.android.intentresolver.ProfileHelper; import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.data.repository.DevicePolicyResources; +import com.android.intentresolver.shared.model.Profile; + +import java.util.List; /** - * 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. + * Empty state provider that informs about a lack of cross profile sharing. It will return + * an empty state in case there are no intents which can be forwarded to another profile. */ public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { - private final UserHandle mPersonalProfileUserHandle; - private final EmptyState mNoWorkToPersonalEmptyState; - private final EmptyState mNoPersonalToWorkEmptyState; + private final ProfileHelper mProfileHelper; + private final DevicePolicyResources mDevicePolicyResources; + private final boolean mIsShare; private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; - private final UserHandle mTabOwnerUserHandleForLaunch; - public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, - EmptyState noWorkToPersonalEmptyState, - EmptyState noPersonalToWorkEmptyState, + public NoCrossProfileEmptyStateProvider( + ProfileHelper profileHelper, + DevicePolicyResources devicePolicyResources, CrossProfileIntentsChecker crossProfileIntentsChecker, - UserHandle tabOwnerUserHandleForLaunch) { - mPersonalProfileUserHandle = personalUserHandle; - mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; - mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; + boolean isShare) { + mProfileHelper = profileHelper; + mDevicePolicyResources = devicePolicyResources; + mIsShare = isShare; mCrossProfileIntentsChecker = crossProfileIntentsChecker; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; } - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - boolean shouldShowBlocker = - !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) - && !mCrossProfileIntentsChecker - .hasCrossProfileIntents(resolverListAdapter.getIntents(), - mTabOwnerUserHandleForLaunch.getIdentifier(), - resolverListAdapter.getUserHandle().getIdentifier()); - - if (!shouldShowBlocker) { - return null; - } - - if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { - return mNoWorkToPersonalEmptyState; - } else { - return mNoPersonalToWorkEmptyState; + private boolean hasCrossProfileIntents(List<Intent> intents, Profile source, Profile target) { + if (source.getPrimary().getHandle().equals(target.getPrimary().getHandle())) { + return true; } + // Note: Use of getPrimary() here also handles delegation of CLONE profile to parent. + return mCrossProfileIntentsChecker.hasCrossProfileIntents(intents, + source.getPrimary().getId(), target.getPrimary().getId()); } + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter adapter) { + Profile launchedBy = mProfileHelper.getLaunchedAsProfile(); + Profile tabOwner = requireNonNull(mProfileHelper.findProfile(adapter.getUserHandle())); + + // When sharing into or out of Private profile, perform the check using the parent profile + // instead. (Hard-coded application of CROSS_PROFILE_CONTENT_SHARING_DELEGATE_FROM_PARENT) - /** - * 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( - @NonNull Context context, - String devicePolicyStringTitleId, - @StringRes int defaultTitleResource, - String devicePolicyStringSubtitleId, - @StringRes int defaultSubtitleResource, - int devicePolicyEventId, - @NonNull String devicePolicyEventCategory) { - mContext = context; - mDevicePolicyStringTitleId = devicePolicyStringTitleId; - mDefaultTitleResource = defaultTitleResource; - mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId; - mDefaultSubtitleResource = defaultSubtitleResource; - mEventId = devicePolicyEventId; - mEventCategory = devicePolicyEventCategory; - } + Profile effectiveSource = launchedBy; + Profile effectiveTarget = tabOwner; - @Nullable - @Override - public String getTitle() { - return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( - mDevicePolicyStringTitleId, - () -> mContext.getString(mDefaultTitleResource)); + // Assumption baked into design: "Personal" profile is the parent of all other profiles. + if (launchedBy.getType() == Profile.Type.PRIVATE) { + effectiveSource = mProfileHelper.getPersonalProfile(); } - @Nullable - @Override - public String getSubtitle() { - return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( - mDevicePolicyStringSubtitleId, - () -> mContext.getString(mDefaultSubtitleResource)); + if (tabOwner.getType() == Profile.Type.PRIVATE) { + effectiveTarget = mProfileHelper.getPersonalProfile(); } - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger.createEvent(mEventId) - .setStrings(mEventCategory) - .write(); + // Allow access to the tab when there is at least one target permitted to cross profiles. + if (hasCrossProfileIntents(adapter.getIntents(), effectiveSource, effectiveTarget)) { + return null; } - @Override - public boolean shouldSkipDataRebuild() { - return true; + switch (tabOwner.getType()) { + case PERSONAL: + return new DevicePolicyBlockerEmptyState( + mDevicePolicyResources.getCrossProfileBlocked(), + mDevicePolicyResources.toPersonalBlockedByPolicyMessage(mIsShare), + RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, + METRICS_CATEGORY_CHOOSER); + + case WORK: + return new DevicePolicyBlockerEmptyState( + mDevicePolicyResources.getCrossProfileBlocked(), + mDevicePolicyResources.toWorkBlockedByPolicyMessage(mIsShare), + RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, + METRICS_CATEGORY_CHOOSER); + + case PRIVATE: + return new DevicePolicyBlockerEmptyState( + mDevicePolicyResources.getCrossProfileBlocked(), + mDevicePolicyResources.toPrivateBlockedByPolicyMessage(mIsShare), + /* Suppress log event. TODO: Define a new metrics event for this? */ -1, + METRICS_CATEGORY_CHOOSER); } + return null; } } diff --git a/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java b/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java new file mode 100644 index 00000000..e9de3221 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.app.admin.DevicePolicyEventLogger; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public 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/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java index 612828e0..f78d1ca2 100644 --- a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -18,19 +18,21 @@ package com.android.intentresolver.emptystate; import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; -import android.app.admin.DevicePolicyEventLogger; +import static java.util.Objects.requireNonNull; + import android.app.admin.DevicePolicyManager; import android.content.Context; import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.ProfileAvailability; +import com.android.intentresolver.ProfileHelper; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.WorkProfileAvailabilityManager; +import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.shared.model.Profile; /** * Chooser/ResolverActivity empty state provider that returns empty state which is shown when @@ -38,20 +40,20 @@ import com.android.intentresolver.WorkProfileAvailabilityManager; */ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { - private final UserHandle mWorkProfileUserHandle; - private final WorkProfileAvailabilityManager mWorkProfileAvailability; + private final ProfileHelper mProfileHelper; + private final ProfileAvailability mProfileAvailability; private final String mMetricsCategory; private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; private final Context mContext; public WorkProfilePausedEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @NonNull WorkProfileAvailabilityManager workProfileAvailability, + ProfileHelper profileHelper, + ProfileAvailability profileAvailability, @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, @NonNull String metricsCategory) { mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mWorkProfileAvailability = workProfileAvailability; + mProfileHelper = profileHelper; + mProfileAvailability = profileAvailability; mMetricsCategory = metricsCategory; mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; } @@ -59,56 +61,34 @@ public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { @Nullable @Override public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) - || !mWorkProfileAvailability.isQuietModeEnabled() - || resolverListAdapter.getCount() == 0) { + UserHandle userHandle = resolverListAdapter.getUserHandle(); + if (!mProfileHelper.getWorkProfilePresent()) { + return null; + } + Profile workProfile = requireNonNull(mProfileHelper.getWorkProfile()); + + // Policy: only show the "Work profile paused" state when: + // * provided list adapter is from the work profile + // * the list adapter is not empty + // * work profile quiet mode is _enabled_ (unavailable) + + if (!userHandle.equals(workProfile.getPrimary().getHandle()) + || resolverListAdapter.getCount() == 0 + || mProfileAvailability.isAvailable(workProfile)) { return null; } - final String title = mContext.getSystemService(DevicePolicyManager.class) + 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) -> { + return new WorkProfileOffEmptyState(title, /* EmptyState.ClickListener */ (tab) -> { tab.showSpinner(); if (mOnSwitchOnWorkSelectedListener != null) { mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); } - mWorkProfileAvailability.requestQuietModeEnabled(false); + mProfileAvailability.requestQuietModeState(workProfile, false); }, 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/v2/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt index 6c36e6aa..2ba08c90 100644 --- a/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt +++ b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ext +package com.android.intentresolver.ext import android.os.Bundle import android.os.Parcelable diff --git a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt b/java/src/com/android/intentresolver/ext/IntentExt.kt index 8c2d7277..127dbf86 100644 --- a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt +++ b/java/src/com/android/intentresolver/ext/IntentExt.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.ext +package com.android.intentresolver.ext import android.content.Intent import java.util.function.Predicate diff --git a/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt b/java/src/com/android/intentresolver/ext/ParcelExt.kt index b0ec97f4..68ea600f 100644 --- a/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt +++ b/java/src/com/android/intentresolver/ext/ParcelExt.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ext +package com.android.intentresolver.ext import android.os.Parcel diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 036b686b..7cf9d2e9 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -40,7 +40,6 @@ import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.FeatureFlags; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter.ViewHolder; -import com.android.internal.annotations.VisibleForTesting; import com.google.android.collect.Lists; @@ -50,7 +49,6 @@ import com.google.android.collect.Lists; * 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> { /** @@ -68,15 +66,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. * 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); @@ -89,7 +78,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. 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_AZ_LABEL = 4; private static final int VIEW_TYPE_CALLER_AND_RANK = 5; private static final int VIEW_TYPE_FOOTER = 6; @@ -151,9 +139,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. @Override public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { - if (mFeatureFlags.scrollablePreview()) { - mRecyclerView = recyclerView; - } + mRecyclerView = recyclerView; } @Override @@ -199,8 +185,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. public int getRowCount() { return (int) ( - getSystemRowCount() - + getServiceTargetRowCount() + getServiceTargetRowCount() + getCallerAndRankedTargetRowCount() + getAzLabelRowCount() + Math.ceil( @@ -209,29 +194,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. ); } - /** - * 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() - || mFeatureFlags.scrollablePreview()) { - return 0; - } - - if (!mShouldShowContentPreview) { - return 0; - } - - if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) { - return 0; - } - - return 1; - } - public int getFooterRowCount() { return 1; } @@ -262,15 +224,13 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. return -1; } - return getSystemRowCount() - + getServiceTargetRowCount() + return getServiceTargetRowCount() + getCallerAndRankedTargetRowCount(); } @Override public int getItemCount() { - return getSystemRowCount() - + getServiceTargetRowCount() + return getServiceTargetRowCount() + getCallerAndRankedTargetRowCount() + getAzLabelRowCount() + mChooserListAdapter.getAlphaTargetCount() @@ -281,12 +241,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { switch (viewType) { - case VIEW_TYPE_CONTENT_PREVIEW: - return new ItemViewHolder( - mChooserActivityDelegate.buildContentPreview(parent), - viewType, - null, - null); case VIEW_TYPE_AZ_LABEL: return new ItemViewHolder( createAzLabelView(parent), @@ -357,10 +311,8 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. @Override public int getItemViewType(int position) { - int count; - - int countSum = (count = getSystemRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; + int count = 0; + int countSum = count; countSum += (count = getServiceTargetRowCount()); if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; @@ -557,8 +509,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. } int getListPosition(int position) { - position -= getSystemRowCount(); - final int serviceCount = mChooserListAdapter.getServiceTargetCount(); final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow); if (position < serviceRows) { diff --git a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt new file mode 100644 index 00000000..b3054231 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.icons + +import android.content.ComponentName +import android.graphics.drawable.Drawable +import android.os.UserHandle +import androidx.collection.LruCache +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.chooser.SelectableTargetInfo +import java.util.function.Consumer +import javax.annotation.concurrent.GuardedBy +import javax.inject.Qualifier + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class Caching + +private typealias IconCache = LruCache<ComponentName, Drawable> + +class CachingTargetDataLoader( + private val targetDataLoader: TargetDataLoader, + private val cacheSize: Int = 100, +) : TargetDataLoader() { + @GuardedBy("self") private val perProfileIconCache = HashMap<UserHandle, IconCache>() + + override fun getOrLoadAppTargetIcon( + info: DisplayResolveInfo, + userHandle: UserHandle, + callback: Consumer<Drawable> + ): Drawable? { + val cacheKey = info.toCacheKey() + return getCachedAppIcon(cacheKey, userHandle) + ?: targetDataLoader.getOrLoadAppTargetIcon(info, userHandle) { drawable -> + getProfileIconCache(userHandle).put(cacheKey, drawable) + callback.accept(drawable) + } + } + + override fun loadDirectShareIcon( + info: SelectableTargetInfo, + userHandle: UserHandle, + callback: Consumer<Drawable> + ) = targetDataLoader.loadDirectShareIcon(info, userHandle, callback) + + override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) = + targetDataLoader.loadLabel(info, callback) + + override fun getOrLoadLabel(info: DisplayResolveInfo) = targetDataLoader.getOrLoadLabel(info) + + private fun getCachedAppIcon(component: ComponentName, userHandle: UserHandle): Drawable? = + getProfileIconCache(userHandle)[component] + + private fun getProfileIconCache(userHandle: UserHandle): IconCache = + synchronized(perProfileIconCache) { + perProfileIconCache.getOrPut(userHandle) { IconCache(cacheSize) } + } + + private fun DisplayResolveInfo.toCacheKey() = + ComponentName( + resolveInfo.activityInfo.packageName, + resolveInfo.activityInfo.name, + ) +} diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index 054fbe71..1a724d73 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -62,11 +62,11 @@ class DefaultTargetDataLoader( ) } - override fun loadAppTargetIcon( + override fun getOrLoadAppTargetIcon( info: DisplayResolveInfo, userHandle: UserHandle, callback: Consumer<Drawable>, - ) { + ): Drawable? { val taskId = nextTaskId.getAndIncrement() LoadIconTask(context, info, userHandle, presentationFactory) { result -> removeTask(taskId) @@ -74,6 +74,7 @@ class DefaultTargetDataLoader( } .also { addTask(taskId, it) } .executeOnExecutor(executor) + return null } override fun loadDirectShareIcon( diff --git a/java/src/com/android/intentresolver/icons/LabelInfo.kt b/java/src/com/android/intentresolver/icons/LabelInfo.kt index a9c4cd77..4b60d607 100644 --- a/java/src/com/android/intentresolver/icons/LabelInfo.kt +++ b/java/src/com/android/intentresolver/icons/LabelInfo.kt @@ -16,4 +16,4 @@ package com.android.intentresolver.icons -class LabelInfo(val label: CharSequence?, val subLabel: CharSequence?) +data class LabelInfo(val label: CharSequence?, val subLabel: CharSequence?) diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt index 07c62177..7789df44 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -25,11 +25,11 @@ import java.util.function.Consumer /** A target data loader contract. Added to support testing. */ abstract class TargetDataLoader { /** Load an app target icon */ - abstract fun loadAppTargetIcon( + abstract fun getOrLoadAppTargetIcon( info: DisplayResolveInfo, userHandle: UserHandle, callback: Consumer<Drawable>, - ) + ): Drawable? /** Load a shortcut icon */ abstract fun loadDirectShareIcon( diff --git a/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt index 4e8783f8..9c0acb11 100644 --- a/java/src/com/android/intentresolver/v2/icons/TargetDataLoaderModule.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt @@ -14,12 +14,10 @@ * limitations under the License. */ -package com.android.intentresolver.v2.icons +package com.android.intentresolver.icons import android.content.Context import androidx.lifecycle.Lifecycle -import com.android.intentresolver.icons.DefaultTargetDataLoader -import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.inject.ActivityOwned import dagger.Module import dagger.Provides @@ -37,4 +35,10 @@ object TargetDataLoaderModule { @ActivityContext context: Context, @ActivityOwned lifecycle: Lifecycle, ): TargetDataLoader = DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false) + + @Provides + @ActivityScoped + @Caching + fun cachingTargetDataLoader(targetDataLoader: TargetDataLoader): TargetDataLoader = + CachingTargetDataLoader(targetDataLoader) } diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt new file mode 100644 index 00000000..bbd25eb7 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.content.Intent +import android.net.Uri +import android.service.chooser.ChooserAction +import androidx.lifecycle.SavedStateHandle +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.viewmodel.readChooserRequest +import com.android.intentresolver.util.ownedByCurrentUser +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Qualifier + +@Module +@InstallIn(ViewModelComponent::class) +object ActivityModelModule { + @Provides + fun provideActivityModel(savedStateHandle: SavedStateHandle): ActivityModel = + requireNotNull(savedStateHandle[ActivityModel.ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! (${ActivityModel.ACTIVITY_MODEL_KEY})" + } + + @Provides + @ChooserIntent + fun chooserIntent(activityModel: ActivityModel): Intent = activityModel.intent + + @Provides + @ViewModelScoped + fun provideInitialRequest( + activityModel: ActivityModel, + flags: ChooserServiceFlags, + ): ValidationResult<ChooserRequest> = readChooserRequest(activityModel, flags) + + @Provides + fun provideChooserRequest( + initialRequest: ValidationResult<ChooserRequest>, + ): ChooserRequest = + requireNotNull((initialRequest as? Valid)?.value) { + "initialRequest is Invalid, no chooser request available" + } + + @Provides + @TargetIntent + fun targetIntent(chooserReq: ValidationResult<ChooserRequest>): Intent = + requireNotNull((chooserReq as? Valid)?.value?.targetIntent) { "no target intent available" } + + @Provides + fun customActions(chooserReq: ValidationResult<ChooserRequest>): List<ChooserAction> = + requireNotNull((chooserReq as? Valid)?.value?.chooserActions) { + "no chooser actions available" + } + + @Provides + @ViewModelScoped + @ContentUris + fun selectedUris(chooserRequest: ValidationResult<ChooserRequest>): List<Uri> = + requireNotNull((chooserRequest as? Valid)?.value?.targetIntent?.contentUris?.toList()) { + "no selected uris available" + } + + @Provides + @FocusedItemIndex + fun focusedItemIndex(chooserReq: ValidationResult<ChooserRequest>): Int = + requireNotNull((chooserReq as? Valid)?.value?.focusedItemPosition) { + "no focused item position available" + } + + @Provides + @AdditionalContent + fun additionalContentUri(chooserReq: ValidationResult<ChooserRequest>): Uri = + requireNotNull((chooserReq as? Valid)?.value?.additionalContentUri) { + "no additional content uri available" + } +} + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class FocusedItemIndex + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AdditionalContent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ChooserIntent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ContentUris + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class TargetIntent + +private val Intent.contentUris: Sequence<Uri> + get() = sequence { + if (Intent.ACTION_SEND == action) { + getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + ?.takeIf { it.ownedByCurrentUser } + ?.let { yield(it) } + } else { + getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri -> + if (uri.ownedByCurrentUser) { + yield(uri) + } + } + } + } diff --git a/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt index e0f8e88b..5fbdf090 100644 --- a/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt +++ b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt @@ -16,6 +16,10 @@ package com.android.intentresolver.inject +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.Process import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -26,6 +30,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +// thread +private const val BROADCAST_SLOW_DISPATCH_THRESHOLD = 1000L +private const val BROADCAST_SLOW_DELIVERY_THRESHOLD = 1000L + @Module @InstallIn(SingletonComponent::class) object ConcurrencyModule { @@ -40,4 +48,25 @@ object ConcurrencyModule { CoroutineScope(SupervisorJob() + mainDispatcher) @Provides @Background fun backgroundDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Singleton + @Broadcast + fun provideBroadcastLooper(): Looper { + val thread = HandlerThread("BroadcastReceiver", Process.THREAD_PRIORITY_BACKGROUND) + thread.start() + thread.looper.setSlowLogThresholdMs( + BROADCAST_SLOW_DISPATCH_THRESHOLD, + BROADCAST_SLOW_DELIVERY_THRESHOLD + ) + return thread.looper + } + + /** Provide a BroadcastReceiver Executor (for sending and receiving broadcasts). */ + @Provides + @Singleton + @Broadcast + fun provideBroadcastHandler(@Broadcast looper: Looper): Handler { + return Handler(looper) + } } diff --git a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt index 0f9a18c1..d7be67db 100644 --- a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt +++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.inject import android.service.chooser.FeatureFlagsImpl as ChooserServiceFlagsImpl diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt index 157e8f76..77315cac 100644 --- a/java/src/com/android/intentresolver/inject/Qualifiers.kt +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -23,6 +23,11 @@ import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) +annotation class ViewModelOwned + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) annotation class ApplicationOwned @Qualifier @@ -30,6 +35,8 @@ annotation class ApplicationOwned @Retention(AnnotationRetention.RUNTIME) annotation class ApplicationUser +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Broadcast + @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ProfileParent @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt index e517800d..af054625 100644 --- a/java/src/com/android/intentresolver/inject/SingletonModule.kt +++ b/java/src/com/android/intentresolver/inject/SingletonModule.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.inject import android.content.Context diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt index 32894d43..2a123dc7 100644 --- a/java/src/com/android/intentresolver/inject/SystemServices.kt +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -17,13 +17,19 @@ package com.android.intentresolver.inject import android.app.ActivityManager import android.app.admin.DevicePolicyManager +import android.app.prediction.AppPredictionManager import android.content.ClipboardManager +import android.content.ContentInterface +import android.content.ContentResolver import android.content.Context import android.content.pm.LauncherApps import android.content.pm.ShortcutManager import android.os.UserManager import android.view.WindowManager import androidx.core.content.getSystemService +import com.android.intentresolver.data.repository.UserScopedService +import com.android.intentresolver.data.repository.UserScopedServiceImpl +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -52,9 +58,13 @@ class ClipboardManagerModule { @Module @InstallIn(SingletonComponent::class) -class ContentResolverModule { - @Provides - fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver) +interface ContentResolverModule { + @Binds fun bindContentInterface(cr: ContentResolver): ContentInterface + + companion object { + @Provides + fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver) + } } @Module @@ -81,10 +91,29 @@ class PackageManagerModule { @Module @InstallIn(SingletonComponent::class) +class PredictionManagerModule { + @Provides + fun scopedPredictionManager( + @ApplicationContext ctx: Context, + ): UserScopedService<AppPredictionManager> { + return UserScopedServiceImpl(ctx, AppPredictionManager::class) + } +} + +@Module +@InstallIn(SingletonComponent::class) class ShortcutManagerModule { @Provides - fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager = - ctx.requireSystemService() + fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager { + return ctx.requireSystemService() + } + + @Provides + fun scopedShortcutManager( + @ApplicationContext ctx: Context, + ): UserScopedService<ShortcutManager> { + return UserScopedServiceImpl(ctx, ShortcutManager::class) + } } @Module @@ -92,6 +121,11 @@ class ShortcutManagerModule { class UserManagerModule { @Provides fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService() + + @Provides + fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService<UserManager> { + return UserScopedServiceImpl(ctx, UserManager::class) + } } @Module diff --git a/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt new file mode 100644 index 00000000..4dda2653 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel + +@Module +@InstallIn(ViewModelComponent::class) +object ViewModelCoroutineScopeModule { + @Provides + @ViewModelScoped + @ViewModelOwned + fun viewModelScope(@Main dispatcher: CoroutineDispatcher, lifecycle: ViewModelLifecycle) = + lifecycle.asCoroutineScope(dispatcher) +} + +fun ViewModelLifecycle.asCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) = + CoroutineScope(context).also { addOnClearedListener { it.cancel() } } diff --git a/java/src/com/android/intentresolver/logging/EventLogImpl.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java index 84029e76..39d23865 100644 --- a/java/src/com/android/intentresolver/logging/EventLogImpl.java +++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java @@ -379,7 +379,9 @@ public class EventLogImpl implements EventLog { @UiEvent(doc = "Sharesheet app share ranking timed out.") SHARESHEET_APP_SHARE_RANKING_TIMEOUT(831), @UiEvent(doc = "Sharesheet empty direct share row.") - SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828); + SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828), + @UiEvent(doc = "Shareousel payload item toggled") + SHARESHEET_PAYLOAD_TOGGLED(1662); private final int mId; SharesheetStandardEvent(int id) { diff --git a/java/src/com/android/intentresolver/logging/EventLogModule.kt b/java/src/com/android/intentresolver/logging/EventLogModule.kt index eba8ecc8..73af7d37 100644 --- a/java/src/com/android/intentresolver/logging/EventLogModule.kt +++ b/java/src/com/android/intentresolver/logging/EventLogModule.kt @@ -24,14 +24,14 @@ import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped @Module -@InstallIn(ActivityComponent::class) +@InstallIn(ActivityRetainedComponent::class) interface EventLogModule { - @Binds @ActivityScoped fun eventLog(value: EventLogImpl): EventLog + @Binds @ActivityRetainedScoped fun eventLog(value: EventLogImpl): EventLog companion object { @Provides diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index 724fa849..4871ef4d 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -20,6 +20,7 @@ import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.os.BadParcelableException; @@ -37,7 +38,6 @@ import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.logging.EventLog; -import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -135,7 +135,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC user, (UsageStatsManager) userContext.getSystemService(Context.USAGE_STATS_SERVICE)); } - mAzComparator = new AzInfoComparator(launchedFromContext); + mAzComparator = new ResolveInfoAzInfoComparator(launchedFromContext); mPromoteToFirst = promoteToFirst; } @@ -203,8 +203,8 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC } if (mHttp) { - final boolean lhsSpecific = ResolverActivity.isSpecificUriMatch(lhs.match); - final boolean rhsSpecific = ResolverActivity.isSpecificUriMatch(rhs.match); + final boolean lhsSpecific = isSpecificUriMatch(lhs.match); + final boolean rhsSpecific = isSpecificUriMatch(rhs.match); if (lhsSpecific != rhsSpecific) { return lhsSpecific ? -1 : 1; } @@ -226,6 +226,13 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC return compare(lhs, rhs); } + /** 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; + } + /** * Delegated to when used as a {@link Comparator<ResolvedComponentInfo>} if there is not a * special case. The {@link ResolveInfo ResolveInfos} are the first {@link ResolveInfo} in @@ -306,24 +313,4 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC mAfterCompute = null; } - /** - * Sort intents alphabetically based on package name. - */ - class AzInfoComparator implements Comparator<ResolveInfo> { - Collator mCollator; - AzInfoComparator(Context context) { - mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); - } - - @Override - public int compare(ResolveInfo lhsp, ResolveInfo rhsp) { - if (lhsp == null) { - return -1; - } else if (rhsp == null) { - return 1; - } - return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName); - } - } - } diff --git a/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java b/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java new file mode 100644 index 00000000..411d0c6e --- /dev/null +++ b/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.model; + +import android.content.Context; +import android.content.pm.ResolveInfo; + +import java.text.Collator; +import java.util.Comparator; + +/** + * Sort intents alphabetically based on package name. + */ +public class ResolveInfoAzInfoComparator<T extends ResolveInfo> implements Comparator<T> { + Collator mCollator; + + public ResolveInfoAzInfoComparator(Context context) { + mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); + } + + @Override + public int compare(ResolveInfo lhsp, ResolveInfo rhsp) { + if (lhsp == null) { + return -1; + } else if (rhsp == null) { + return 1; + } + return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName); + } +} diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index f3804154..963091b5 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -28,6 +28,7 @@ import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.metrics.LogMaker; +import android.os.Handler; import android.os.IBinder; import android.os.Message; import android.os.RemoteException; @@ -48,6 +49,7 @@ import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.google.android.collect.Lists; +import java.lang.ref.WeakReference; import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; @@ -392,20 +394,7 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom } public final IResolverRankerResult resolverRankerResult = - new IResolverRankerResult.Stub() { - @Override - public void sendResult(List<ResolverTarget> targets) throws RemoteException { - if (DEBUG) { - Log.d(TAG, "Sending Result back to Resolver: " + targets); - } - synchronized (mLock) { - final Message msg = Message.obtain(); - msg.what = RANKER_SERVICE_RESULT; - msg.obj = targets; - mHandler.sendMessage(msg); - } - } - }; + new ResolverRankerResultCallback(mLock, mHandler); @Override public void onServiceConnected(ComponentName name, IBinder service) { @@ -437,6 +426,32 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom } } + private static class ResolverRankerResultCallback extends IResolverRankerResult.Stub { + private final Object mLock; + private final WeakReference<Handler> mHandlerRef; + + private ResolverRankerResultCallback(Object lock, Handler handler) { + mLock = lock; + mHandlerRef = new WeakReference<>(handler); + } + + @Override + public void sendResult(List<ResolverTarget> targets) throws RemoteException { + if (DEBUG) { + Log.d(TAG, "Sending Result back to Resolver: " + targets); + } + synchronized (mLock) { + final Message msg = Message.obtain(); + msg.what = RANKER_SERVICE_RESULT; + msg.obj = targets; + Handler handler = mHandlerRef.get(); + if (handler != null) { + handler.sendMessage(msg); + } + } + } + } + @Override void beforeCompute() { super.beforeCompute(); diff --git a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/platform/AppPredictionModule.kt index 9ca9d871..415d5f7d 100644 --- a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt +++ b/java/src/com/android/intentresolver/platform/AppPredictionModule.kt @@ -13,12 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.platform +package com.android.intentresolver.platform import android.content.pm.PackageManager import dagger.Module import dagger.Provides -import dagger.Reusable import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Qualifier @@ -33,13 +32,11 @@ annotation class AppPredictionAvailable @InstallIn(SingletonComponent::class) object AppPredictionModule { - /** - * Eventually replaced with: Optional<AppPredictionRepository>, etc. - */ + /** Eventually replaced with: Optional<AppPredictionRepository>, etc. */ @Provides @Singleton @AppPredictionAvailable fun isAppPredictionAvailable(packageManager: PackageManager): Boolean { return packageManager.appPredictionServicePackageName != null } -}
\ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt b/java/src/com/android/intentresolver/platform/ImageEditorModule.kt index efbf053e..24257968 100644 --- a/java/src/com/android/intentresolver/v2/platform/ImageEditorModule.kt +++ b/java/src/com/android/intentresolver/platform/ImageEditorModule.kt @@ -1,4 +1,20 @@ -package com.android.intentresolver.v2.platform +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform import android.content.ComponentName import android.content.res.Resources diff --git a/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt index 25ee9198..6cb30b41 100644 --- a/java/src/com/android/intentresolver/v2/platform/NearbyShareModule.kt +++ b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt @@ -1,4 +1,20 @@ -package com.android.intentresolver.v2.platform +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform import android.content.ComponentName import android.content.res.Resources diff --git a/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt index 531152ba..0c802c97 100644 --- a/java/src/com/android/intentresolver/v2/platform/PlatformSecureSettings.kt +++ b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt @@ -1,4 +1,20 @@ -package com.android.intentresolver.v2.platform +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform import android.content.ContentResolver import android.provider.Settings diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt b/java/src/com/android/intentresolver/platform/SecureSettings.kt index 62ee8ae9..8a1dc531 100644 --- a/java/src/com/android/intentresolver/v2/platform/SecureSettings.kt +++ b/java/src/com/android/intentresolver/platform/SecureSettings.kt @@ -1,4 +1,20 @@ -package com.android.intentresolver.v2.platform +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform import android.provider.Settings.SettingNotFoundException diff --git a/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt new file mode 100644 index 00000000..fa3ee4fe --- /dev/null +++ b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SecureSettingsModule { + + @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings +} diff --git a/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java b/java/src/com/android/intentresolver/profiles/AdapterBinder.java index c5b35273..f92a140f 100644 --- a/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java +++ b/java/src/com/android/intentresolver/profiles/AdapterBinder.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.profiles; +package com.android.intentresolver.profiles; /** * Delegate to set up a given adapter and page view to be used together. diff --git a/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java index 0ee9d141..8aee0da1 100644 --- a/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.profiles; +package com.android.intentresolver.profiles; import android.content.Context; import android.os.UserHandle; @@ -27,7 +27,6 @@ import androidx.viewpager.widget.PagerAdapter; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRecyclerViewAccessibilityDelegate; -import com.android.intentresolver.FeatureFlags; import com.android.intentresolver.R; import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; @@ -56,8 +55,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - int maxTargetsPerRow, - FeatureFlags featureFlags) { + int maxTargetsPerRow) { this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), @@ -67,8 +65,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context), - featureFlags); + new BottomPaddingOverrideSupplier(context)); } private ChooserMultiProfilePagerAdapter( @@ -80,10 +77,9 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, - FeatureFlags featureFlags) { + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { super( - gridAdapter -> gridAdapter.getListAdapter(), + gridAdapter -> gridAdapter.getListAdapter(), adapterBinder, tabs, emptyStateProvider, @@ -91,7 +87,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - () -> makeProfileView(context, featureFlags), + () -> makeProfileView(context), bottomPaddingOverrideSupplier); mAdapterBinder = adapterBinder; mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; @@ -116,12 +112,10 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< } } - private static ViewGroup makeProfileView( - Context context, FeatureFlags featureFlags) { + private static ViewGroup makeProfileView(Context context) { LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = featureFlags.scrollablePreview() - ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) - : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); + ViewGroup rootView = + (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false); RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); recyclerView.setAccessibilityDelegateCompat( new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); @@ -151,6 +145,16 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< } } + /** Cleanup system resources */ + public void destroy() { + for (int i = 0, count = getItemCount(); i < count; i++) { + ChooserGridAdapter adapter = getPageAdapterForIndex(i); + if (adapter != null) { + adapter.getListAdapter().onDestroy(); + } + } + } + private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { private final Context mContext; private int mBottomOffset; diff --git a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java index 43785db3..11a6caca 100644 --- a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.profiles; +package com.android.intentresolver.profiles; -import android.annotation.IntDef; import android.annotation.Nullable; import android.os.Trace; import android.os.UserHandle; @@ -32,6 +31,7 @@ import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.emptystate.EmptyState; import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.shared.model.Profile; import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -61,10 +61,11 @@ public class MultiProfilePagerAdapter< SinglePageAdapterT, ListAdapterT extends ResolverListAdapter> extends PagerAdapter { - public static final int PROFILE_PERSONAL = 0; - public static final int PROFILE_WORK = 1; + public static final int PROFILE_PERSONAL = Profile.Type.PERSONAL.ordinal(); + public static final int PROFILE_WORK = Profile.Type.WORK.ordinal(); - @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) + // Removed, must be constants. This is only used for linting anyway. + // @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) public @interface ProfileType {} private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; @@ -244,6 +245,7 @@ public class MultiProfilePagerAdapter< Runnable onTabChangeListener, OnProfileSelectedListener clientOnProfileSelectedListener) { tabHost.setup(); + tabHost.getTabWidget().removeAllViews(); viewPager.setSaveEnabled(false); for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { @@ -300,15 +302,7 @@ public class MultiProfilePagerAdapter< viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { @Override public void onPageSelected(int position) { - mCurrentPage = position; - if (!mLoadedPages.contains(position)) { - rebuildActiveTab(true); - mLoadedPages.add(position); - } - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageSelected( - getProfileForPageNumber(position), position); - } + MultiProfilePagerAdapter.this.onPageSelected(position); } @Override @@ -323,6 +317,18 @@ public class MultiProfilePagerAdapter< mLoadedPages.add(mCurrentPage); } + private void onPageSelected(int position) { + mCurrentPage = position; + if (!mLoadedPages.contains(position)) { + rebuildActiveTab(true); + mLoadedPages.add(position); + } + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageSelected( + getProfileForPageNumber(position), position); + } + } + public void clearInactiveProfileCache() { forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber)); } @@ -349,6 +355,13 @@ public class MultiProfilePagerAdapter< return mCurrentPage; } + /** + * Set active adapter page. A support method for the poayload reselection logic. + */ + public void setCurrentPage(int page) { + onPageSelected(page); + } + public final @ProfileType int getActiveProfile() { return getProfileForPageNumber(getCurrentPage()); } diff --git a/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java b/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java index 7bdbec4c..e6299954 100644 --- a/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java +++ b/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.profiles; +package com.android.intentresolver.profiles; import androidx.viewpager.widget.ViewPager; diff --git a/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java b/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java index 3dbbd4d0..7989551a 100644 --- a/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java +++ b/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.profiles; +package com.android.intentresolver.profiles; /** * Listener for when the user switches on the work profile from the work tab. diff --git a/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java b/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java index e2e9c19d..61c7c670 100644 --- a/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java +++ b/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java @@ -14,11 +14,11 @@ * limitations under the License. */ -package com.android.intentresolver.v2.profiles; +package com.android.intentresolver.profiles; import android.view.ViewGroup; -import com.android.intentresolver.v2.emptystate.EmptyStateUiHelper; +import com.android.intentresolver.emptystate.EmptyStateUiHelper; import java.util.Optional; import java.util.function.Supplier; diff --git a/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java index e44cf8da..0c669510 100644 --- a/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.profiles; +package com.android.intentresolver.profiles; import android.content.Context; import android.os.UserHandle; diff --git a/java/src/com/android/intentresolver/v2/profiles/TabConfig.java b/java/src/com/android/intentresolver/profiles/TabConfig.java index 994f8aff..320f069a 100644 --- a/java/src/com/android/intentresolver/v2/profiles/TabConfig.java +++ b/java/src/com/android/intentresolver/profiles/TabConfig.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.profiles; +package com.android.intentresolver.profiles; public class TabConfig<PageAdapterT> { final @MultiProfilePagerAdapter.ProfileType int mProfile; diff --git a/java/src/com/android/intentresolver/v2/shared/model/Profile.kt b/java/src/com/android/intentresolver/shared/model/Profile.kt index 6e37174c..c557c151 100644 --- a/java/src/com/android/intentresolver/v2/shared/model/Profile.kt +++ b/java/src/com/android/intentresolver/shared/model/Profile.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package com.android.intentresolver.v2.shared.model +package com.android.intentresolver.shared.model -import com.android.intentresolver.v2.shared.model.Profile.Type +import com.android.intentresolver.shared.model.Profile.Type /** * Associates [users][User] into a [Type] instance. diff --git a/java/src/com/android/intentresolver/v2/shared/model/User.kt b/java/src/com/android/intentresolver/shared/model/User.kt index 97db3280..b544a390 100644 --- a/java/src/com/android/intentresolver/v2/shared/model/User.kt +++ b/java/src/com/android/intentresolver/shared/model/User.kt @@ -14,12 +14,10 @@ * limitations under the License. */ -package com.android.intentresolver.v2.shared.model +package com.android.intentresolver.shared.model import android.annotation.UserIdInt import android.os.UserHandle -import com.android.intentresolver.v2.shared.model.User.Type.FULL -import com.android.intentresolver.v2.shared.model.User.Type.PROFILE /** * A User represents the owner of a distinct set of content. @@ -45,21 +43,10 @@ data class User( ) { val handle: UserHandle = UserHandle.of(id) - val type: Type - get() = role.type - - enum class Type { - FULL, - PROFILE - } - - enum class Role( - /** The type of the role user. */ - val type: Type - ) { - PERSONAL(FULL), - PRIVATE(PROFILE), - WORK(PROFILE), - CLONE(PROFILE) + enum class Role { + PERSONAL, + PRIVATE, + WORK, + CLONE } } diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt index e544e064..c7bd0336 100644 --- a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt +++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt @@ -31,12 +31,13 @@ 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. + * @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, @@ -50,16 +51,19 @@ class AppPredictorFactory( fun create(userHandle: UserHandle): AppPredictor? { if (!appPredictionAvailable) 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) + 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/v2/ui/ActionTitle.java b/java/src/com/android/intentresolver/ui/ActionTitle.java index a1e1c7fa..1cc96fa9 100644 --- a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java +++ b/java/src/com/android/intentresolver/ui/ActionTitle.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.ui; +package com.android.intentresolver.ui; import android.content.Intent; import android.provider.MediaStore; diff --git a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt index 1cd72ba5..0d07af8f 100644 --- a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt +++ b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt @@ -14,14 +14,14 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ui +package com.android.intentresolver.ui import android.content.res.Resources +import com.android.intentresolver.R +import com.android.intentresolver.data.repository.DevicePolicyResources import com.android.intentresolver.inject.ApplicationOwned -import com.android.intentresolver.v2.data.repository.DevicePolicyResources -import com.android.intentresolver.v2.shared.model.Profile +import com.android.intentresolver.shared.model.Profile import javax.inject.Inject -import com.android.intentresolver.R class ProfilePagerResources @Inject @@ -50,4 +50,12 @@ constructor( Profile.Type.PRIVATE -> privateTabAccessibilityLabel } } -}
\ No newline at end of file + + fun noAppsMessage(type: Profile.Type): String { + return when (type) { + Profile.Type.PERSONAL -> devicePolicyResources.noPersonalApps + Profile.Type.WORK -> devicePolicyResources.noWorkApps + Profile.Type.PRIVATE -> resources.getString(R.string.resolver_no_private_apps_available) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/ui/ShareResultSender.kt index 2b01b5e7..7be2076e 100644 --- a/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt +++ b/java/src/com/android/intentresolver/ui/ShareResultSender.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ui +package com.android.intentresolver.ui import android.app.Activity import android.app.compat.CompatChanges @@ -32,7 +32,7 @@ import android.util.Log import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.inject.Main -import com.android.intentresolver.v2.ui.model.ShareAction +import com.android.intentresolver.ui.model.ShareAction import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject diff --git a/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt b/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt new file mode 100644 index 00000000..7239198e --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui + +import android.content.res.Resources +import android.provider.DeviceConfig +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppShortcutLimit + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class EnforceShortcutLimit + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ShortcutRowLimit + +@Module +@InstallIn(SingletonComponent::class) +object ShortcutPolicyModule { + /** + * Defines the limit for the number of shortcut targets provided for any single app. + * + * This value applies to both results from Shortcut-service and app-provided targets on a + * per-package basis. + */ + @Provides + @Singleton + @AppShortcutLimit + fun appShortcutLimit(@ApplicationOwned resources: Resources): Int { + return resources.getInteger(R.integer.config_maxShortcutTargetsPerApp) + } + + /** + * Once this value is no longer necessary it should be replaced in tests with simply replacing + * [AppShortcutLimit]: + * ``` + * @BindValue + * @AppShortcutLimit + * var shortcutLimit = Int.MAX_VALUE + * ``` + */ + @Provides + @Singleton + @EnforceShortcutLimit + fun applyShortcutLimit(): Boolean { + return DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + true + ) + } + + /** + * Defines the limit for the number of shortcuts presented within the direct share row. + * + * This value applies to all displayed direct share targets, including those from Shortcut + * service as well as app-provided targets. + */ + @Provides + @Singleton + @ShortcutRowLimit + fun shortcutRowLimit(@ApplicationOwned resources: Resources): Int { + return resources.getInteger(R.integer.config_chooser_max_targets_per_row) + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/ui/model/ActivityModel.kt index 07b17435..4bcdd69b 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt +++ b/java/src/com/android/intentresolver/ui/model/ActivityModel.kt @@ -13,15 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.ui.model +package com.android.intentresolver.ui.model import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Parcel import android.os.Parcelable -import com.android.intentresolver.v2.ext.readParcelable -import com.android.intentresolver.v2.ext.requireParcelable +import com.android.intentresolver.data.model.ANDROID_APP_SCHEME +import com.android.intentresolver.ext.readParcelable +import com.android.intentresolver.ext.requireParcelable import java.util.Objects /** Contains Activity-scope information about the state when started. */ diff --git a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt index a4f74ca9..363c413d 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt +++ b/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ui.model +package com.android.intentresolver.ui.model import android.content.Intent import android.content.pm.ResolveInfo import android.os.UserHandle -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.ext.isHomeIntent +import com.android.intentresolver.ext.isHomeIntent +import com.android.intentresolver.shared.model.Profile /** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */ data class ResolverRequest( diff --git a/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt b/java/src/com/android/intentresolver/ui/model/ShareAction.kt index e13ef101..4d727b9a 100644 --- a/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt +++ b/java/src/com/android/intentresolver/ui/model/ShareAction.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ui.model +package com.android.intentresolver.ui.model enum class ShareAction { SYSTEM_COPY, diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt index 91eed408..a9b6de7e 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.ui.viewmodel +package com.android.intentresolver.ui.viewmodel import android.content.ComponentName import android.content.Intent @@ -42,18 +42,18 @@ import android.service.chooser.ChooserTarget import com.android.intentresolver.ChooserActivity import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.R +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.ext.hasSendAction +import com.android.intentresolver.ext.ifMatch import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.ui.model.ActivityModel import com.android.intentresolver.util.hasValidIcon -import com.android.intentresolver.v2.ext.hasSendAction -import com.android.intentresolver.v2.ext.ifMatch -import com.android.intentresolver.v2.ui.model.ActivityModel -import com.android.intentresolver.v2.ui.model.ChooserRequest -import com.android.intentresolver.v2.validation.Validation -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.types.IntentOrUri -import com.android.intentresolver.v2.validation.types.array -import com.android.intentresolver.v2.validation.types.value -import com.android.intentresolver.v2.validation.validateFrom +import com.android.intentresolver.validation.Validation +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.types.IntentOrUri +import com.android.intentresolver.validation.types.array +import com.android.intentresolver.validation.types.value +import com.android.intentresolver.validation.validateFrom private const val MAX_CHOOSER_ACTIONS = 5 private const val MAX_INITIAL_INTENTS = 2 @@ -65,10 +65,10 @@ internal fun Intent.maybeAddSendActionFlags() = } fun readChooserRequest( - launch: ActivityModel, + model: ActivityModel, flags: ChooserServiceFlags ): ValidationResult<ChooserRequest> { - val extras = launch.intent.extras ?: Bundle() + val extras = model.intent.extras ?: Bundle() @Suppress("DEPRECATION") return validateFrom(extras::get) { val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() @@ -154,12 +154,12 @@ fun readChooserRequest( isSendActionTarget = isSendAction, targetType = targetIntent.type, launchedFromPackage = - requireNotNull(launch.launchedFromPackage) { + requireNotNull(model.launchedFromPackage) { "launch.fromPackage was null, See Activity.getLaunchedFromPackage()" }, title = customTitle, defaultTitleResource = defaultTitleResource, - referrer = launch.referrer, + referrer = model.referrer, filteredComponentNames = filteredComponents, callerChooserTargets = callerChooserTargets, chooserActions = chooserActions, diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt new file mode 100644 index 00000000..c9cae3db --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ProcessTargetIntentUpdatesInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.data.repository.ChooserRequestRepository +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import dagger.Lazy +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +private const val TAG = "ChooserViewModel" + +@HiltViewModel +class ChooserViewModel +@Inject +constructor( + args: SavedStateHandle, + private val shareouselViewModelProvider: Lazy<ShareouselViewModel>, + private val processUpdatesInteractor: Lazy<ProcessTargetIntentUpdatesInteractor>, + private val fetchPreviewsInteractor: Lazy<FetchPreviewsInteractor>, + @Background private val bgDispatcher: CoroutineDispatcher, + private val flags: ChooserServiceFlags, + /** + * Provided only for the express purpose of early exit in the event of an invalid request. + * + * Note: [request] can only be safely accessed after checking if this value is [Valid]. + */ + val initialRequest: ValidationResult<ChooserRequest>, + private val chooserRequestRepository: Lazy<ChooserRequestRepository>, +) : ViewModel() { + + /** Parcelable-only references provided from the creating Activity */ + val activityModel: ActivityModel = + requireNotNull(args[ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" + } + + val shareouselViewModel: ShareouselViewModel by lazy { + // TODO: consolidate this logic, this would require a consolidated preview view model but + // for now just postpone starting the payload selection preview machinery until it's needed + assert(flags.chooserPayloadToggling()) { + "An attempt to use payload selection preview with the disabled flag" + } + + viewModelScope.launch(bgDispatcher) { processUpdatesInteractor.get().activate() } + viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().activate() } + shareouselViewModelProvider.get() + } + + /** + * A [StateFlow] of [ChooserRequest]. + * + * Note: Only safe to access after checking if [initialRequest] is [Valid]. + */ + val request: StateFlow<ChooserRequest> + get() = chooserRequestRepository.get().chooserRequest.asStateFlow() + + init { + if (initialRequest is Invalid) { + Log.w(TAG, "initialRequest is Invalid, initialization failed") + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt index bbc376ea..856d9fdd 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt @@ -14,19 +14,19 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ui.viewmodel +package com.android.intentresolver.ui.viewmodel import android.os.Bundle import android.os.UserHandle -import com.android.intentresolver.v2.ResolverActivity.PROFILE_PERSONAL -import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.ui.model.ActivityModel -import com.android.intentresolver.v2.ui.model.ResolverRequest -import com.android.intentresolver.v2.validation.Validation -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.types.value -import com.android.intentresolver.v2.validation.validateFrom +import com.android.intentresolver.ResolverActivity.PROFILE_PERSONAL +import com.android.intentresolver.ResolverActivity.PROFILE_WORK +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.validation.Validation +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.types.value +import com.android.intentresolver.validation.validateFrom const val EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER" const val EXTRA_SELECTED_PROFILE = diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt new file mode 100644 index 00000000..a3dc58a6 --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +private const val TAG = "ResolverViewModel" + +@HiltViewModel +class ResolverViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() { + + /** Parcelable-only references provided from the creating Activity */ + val activityModel: ActivityModel = + requireNotNull(args[ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" + } + + /** + * Provided only for the express purpose of early exit in the event of an invalid request. + * + * Note: [request] can only be safely accessed after checking if this value is [Valid]. + */ + internal val initialRequest = readResolverRequest(activityModel) + + private lateinit var _request: MutableStateFlow<ResolverRequest> + + /** + * A [StateFlow] of [ResolverRequest]. + * + * Note: Only safe to access after checking if [initialRequest] is [Valid]. + */ + lateinit var request: StateFlow<ResolverRequest> + private set + + init { + when (initialRequest) { + is Valid -> { + _request = MutableStateFlow(initialRequest.value) + request = _request.asStateFlow() + } + is Invalid -> Log.w(TAG, "initialRequest is Invalid, initialization failed") + } + } +} diff --git a/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt new file mode 100644 index 00000000..e89cb5ca --- /dev/null +++ b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import android.os.CancellationSignal +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Invokes [block] with a [CancellationSignal] that is bound to this coroutine's lifetime; if this + * coroutine is cancelled, then [CancellationSignal.cancel] is promptly invoked. + */ +suspend fun <R> withCancellationSignal(block: suspend (signal: CancellationSignal) -> R): R = + coroutineScope { + val signal = CancellationSignal() + val signalJob = + launch(start = CoroutineStart.UNDISPATCHED) { + try { + awaitCancellation() + } finally { + signal.cancel() + } + } + block(signal).also { signalJob.cancel() } + } diff --git a/java/src/com/android/intentresolver/util/Flow.kt b/java/src/com/android/intentresolver/util/Flow.kt index 1155b9fe..598379f3 100644 --- a/java/src/com/android/intentresolver/util/Flow.kt +++ b/java/src/com/android/intentresolver/util/Flow.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.launch * latest value is emitted. * * Example: - * * ```kotlin * flow { * emit(1) // t=0ms @@ -70,10 +69,11 @@ fun <T> Flow<T>.throttle(periodMs: Long): Flow<T> = channelFlow { // We create delayJob to allow cancellation during the delay period delayJob = launch { delay(timeUntilNextEmit) - sendJob = outerScope.launch(start = CoroutineStart.UNDISPATCHED) { - send(it) - previousEmitTimeMs = SystemClock.elapsedRealtime() - } + sendJob = + outerScope.launch(start = CoroutineStart.UNDISPATCHED) { + send(it) + previousEmitTimeMs = SystemClock.elapsedRealtime() + } } } else { send(it) diff --git a/java/src/com/android/intentresolver/util/ParallelIteration.kt b/java/src/com/android/intentresolver/util/ParallelIteration.kt new file mode 100644 index 00000000..70c46c47 --- /dev/null +++ b/java/src/com/android/intentresolver/util/ParallelIteration.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.yield + +/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */ +suspend fun <A, B> Iterable<A>.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List<B> = + parallelism?.let { permits -> + withSemaphore(permits = permits) { mapParallel { withPermit { block(it) } } } + } + ?: mapParallel(block) + +/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */ +suspend fun <A, B> Sequence<A>.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List<B> = asIterable().mapParallel(parallelism, block) + +private suspend fun <A, B> Iterable<A>.mapParallel(block: suspend (A) -> B): List<B> = + coroutineScope { + map { + async { + yield() + block(it) + } + } + .awaitAll() + } diff --git a/java/src/com/android/intentresolver/util/SyncUtils.kt b/java/src/com/android/intentresolver/util/SyncUtils.kt new file mode 100644 index 00000000..eaebc6ea --- /dev/null +++ b/java/src/com/android/intentresolver/util/SyncUtils.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore + +/** + * Constructs a [Semaphore] for usage within [block], useful for launching a lot of work in parallel + * that needs some synchronization. + */ +inline fun <R> withSemaphore(permits: Int, block: Semaphore.() -> R): R = + Semaphore(permits).run(block) + +/** + * Constructs a [Mutex] for usage within [block], useful for launching a lot of work in parallel + * that needs some synchronization. + */ +inline fun <R> withMutex(block: Mutex.() -> R): R = Mutex().run(block) diff --git a/java/src/com/android/intentresolver/util/cursor/CursorView.kt b/java/src/com/android/intentresolver/util/cursor/CursorView.kt new file mode 100644 index 00000000..eca7d335 --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/CursorView.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util.cursor + +import android.database.Cursor + +/** A [Cursor] that holds values of [E] for each row. */ +interface CursorView<out E> : Cursor { + /** + * Reads the current row from this [CursorView]. A result of `null` indicates that the row could + * not be read / value could not be produced. + */ + fun readRow(): E? +} + +/** + * Returns a [CursorView] from the given [Cursor], and a function [readRow] used to produce the + * value for a single row. + */ +fun <E> Cursor.viewBy(readRow: Cursor.() -> E): CursorView<E> = + object : CursorView<E>, Cursor by this@viewBy { + override fun readRow(): E? = immobilized().readRow() + } + +/** Returns a [CursorView] that begins (index 0) at [newStartIndex] of the given cursor. */ +fun <E> CursorView<E>.startAt(newStartIndex: Int): CursorView<E> = + object : CursorView<E>, Cursor by (this@startAt as Cursor).startAt(newStartIndex) { + override fun readRow(): E? = this@startAt.readRow() + } + +/** Returns a [CursorView] that is truncated to contain only [count] elements. */ +fun <E> CursorView<E>.limit(count: Int): CursorView<E> = + object : CursorView<E>, Cursor by (this@limit as Cursor).limit(count) { + override fun readRow(): E? = this@limit.readRow() + } + +/** Retrieves a single row at index [idx] from the [CursorView]. */ +operator fun <E> CursorView<E>.get(idx: Int): E? = if (moveToPosition(idx)) readRow() else null + +/** Returns a [Sequence] that iterates over the [CursorView] returning each row. */ +fun <E> CursorView<E>.asSequence(): Sequence<E?> = sequence { + for (i in 0 until count) { + yield(get(i)) + } +} diff --git a/java/src/com/android/intentresolver/util/cursor/Cursors.kt b/java/src/com/android/intentresolver/util/cursor/Cursors.kt new file mode 100644 index 00000000..ce768f3b --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/Cursors.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util.cursor + +import android.database.Cursor +import android.database.CursorWrapper + +/** Returns a Cursor that is truncated to contain only [count] elements. */ +fun Cursor.limit(count: Int): Cursor = + object : CursorWrapper(this) { + override fun getCount(): Int = minOf(count, super.getCount()) + + override fun getPosition(): Int = super.getPosition().coerceAtMost(count) + + override fun moveToLast(): Boolean = super.moveToPosition(getCount() - 1) + + override fun isFirst(): Boolean = getCount() != 0 && super.isFirst() + + override fun isLast(): Boolean = getCount() != 0 && super.getPosition() == getCount() - 1 + + override fun isAfterLast(): Boolean = getCount() == 0 || super.getPosition() >= getCount() + + override fun isBeforeFirst(): Boolean = getCount() == 0 || super.isBeforeFirst() + + override fun moveToNext(): Boolean = super.moveToNext() && position < getCount() + + override fun moveToPosition(position: Int): Boolean = + super.moveToPosition(position) && position < getCount() + } + +/** Returns a Cursor that begins (index 0) at [newStartIndex] of the given Cursor. */ +fun Cursor.startAt(newStartIndex: Int): Cursor = + object : CursorWrapper(this) { + override fun getCount(): Int = (super.getCount() - newStartIndex).coerceAtLeast(0) + + override fun getPosition(): Int = (super.getPosition() - newStartIndex).coerceAtLeast(-1) + + override fun moveToFirst(): Boolean = super.moveToPosition(newStartIndex) + + override fun moveToNext(): Boolean = super.moveToNext() && position < count + + override fun moveToPrevious(): Boolean = super.moveToPrevious() && position >= 0 + + override fun moveToPosition(position: Int): Boolean = + super.moveToPosition(position + newStartIndex) && position >= 0 + + override fun isFirst(): Boolean = count != 0 && super.getPosition() == newStartIndex + + override fun isLast(): Boolean = count != 0 && super.isLast() + + override fun isBeforeFirst(): Boolean = count == 0 || super.getPosition() < newStartIndex + + override fun isAfterLast(): Boolean = count == 0 || super.isAfterLast() + } + +/** Returns a read-only non-movable view into the given Cursor. */ +fun Cursor.immobilized(): Cursor = + object : CursorWrapper(this) { + private val unsupported: Nothing + get() = error("unsupported") + + override fun moveToFirst(): Boolean = unsupported + + override fun moveToLast(): Boolean = unsupported + + override fun move(offset: Int): Boolean = unsupported + + override fun moveToPosition(position: Int): Boolean = unsupported + + override fun moveToNext(): Boolean = unsupported + + override fun moveToPrevious(): Boolean = unsupported + } diff --git a/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt new file mode 100644 index 00000000..6e4318dc --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util.cursor + +import android.database.Cursor + +/** A [CursorView] that produces chunks/pages from an underlying cursor. */ +interface PagedCursor<out E> : CursorView<Sequence<E?>> { + /** The configured size of each page produced by this cursor. */ + val pageSize: Int +} + +/** Returns a [PagedCursor] that produces pages of data from the given [CursorView]. */ +fun <E> CursorView<E>.paged(pageSize: Int): PagedCursor<E> = + object : PagedCursor<E>, Cursor by this@paged { + + init { + check(pageSize > 0) { "pageSize must be greater than 0" } + } + + override val pageSize: Int = pageSize + + override fun getCount(): Int = + this@paged.count.let { it / pageSize + minOf(1, it % pageSize) } + + override fun getPosition(): Int = + (this@paged.position / pageSize).let { if (this@paged.position < 0) it - 1 else it } + + override fun moveToNext(): Boolean = moveToPosition(position + 1) + + override fun moveToPrevious(): Boolean = moveToPosition(position - 1) + + override fun moveToPosition(position: Int): Boolean = + this@paged.moveToPosition(position * pageSize) + + override fun readRow(): Sequence<E?> = + this@paged.startAt(position * pageSize).limit(pageSize).asSequence() + } diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt deleted file mode 100644 index 62ace0da..00000000 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2 - -import android.os.UserHandle -import android.os.UserManager -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.core.content.getSystemService -import com.android.intentresolver.AnnotatedUserHandles -import com.android.intentresolver.WorkProfileAvailabilityManager - -/** - * Logic for IntentResolver Activities. Anything that is not the same across activities (including - * test activities) should be in this interface. Expect there to be one implementation for each - * activity, including test activities, but all implementations should delegate to a - * CommonActivityLogic implementation. - */ -interface ActivityLogic : CommonActivityLogic - -/** - * Logic that is common to all IntentResolver activities. Anything that is the same across - * activities (including test activities), should live here. - */ -interface CommonActivityLogic { - /** The tag to use when logging. */ - val tag: String - - /** A reference to the activity owning, and used by, this logic. */ - val activity: ComponentActivity - - /** Current [UserHandle]s retrievable by type. */ - val annotatedUserHandles: AnnotatedUserHandles? - - /** Monitors for changes to work profile availability. */ - val workProfileAvailabilityManager: WorkProfileAvailabilityManager -} - -/** - * Concrete implementation of the [CommonActivityLogic] interface meant to be delegated to by - * [ActivityLogic] implementations. Test implementations of [ActivityLogic] may need to create their - * own [CommonActivityLogic] implementation. - */ -class CommonActivityLogicImpl( - override val tag: String, - override val activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, -) : CommonActivityLogic { - - private val userManager: UserManager = activity.getSystemService()!! - - override val annotatedUserHandles: AnnotatedUserHandles? = - try { - AnnotatedUserHandles.forShareActivity(activity) - } catch (e: SecurityException) { - Log.e(tag, "Request from UID without necessary permissions", e) - null - } - - override val workProfileAvailabilityManager = - WorkProfileAvailabilityManager( - userManager, - annotatedUserHandles?.workProfileUserHandle, - onWorkProfileStatusUpdated, - ) -} diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java deleted file mode 100644 index 9077a18d..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java +++ /dev/null @@ -1,409 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import android.app.Activity; -import android.app.ActivityOptions; -import android.app.PendingIntent; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.service.chooser.ChooserAction; -import android.text.TextUtils; -import android.util.Log; -import android.view.View; - -import androidx.annotation.Nullable; - -import com.android.intentresolver.R; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; -import com.android.intentresolver.logging.EventLog; -import com.android.intentresolver.v2.ui.ShareResultSender; -import com.android.intentresolver.v2.ui.model.ShareAction; -import com.android.intentresolver.widget.ActionRow; -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.Callable; -import java.util.function.Consumer; - -/** - * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application - * requirements of Sharesheet / {@link ChooserActivity}. - */ -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { - /** - * Delegate interface to launch activities when the actions are selected. - */ - public interface ActionActivityStarter { - /** - * Request an activity launch for the provided target. Implementations may choose to exit - * the current activity when the target is launched. - */ - void safelyStartActivityAsPersonalProfileUser(TargetInfo info); - - /** - * Request an activity launch for the provided target, optionally employing the specified - * shared element transition. Implementations may choose to exit the current activity when - * the target is launched. - */ - default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( - TargetInfo info, View sharedElement, String sharedElementName) { - safelyStartActivityAsPersonalProfileUser(info); - } - } - - private static final String TAG = "ChooserActions"; - - private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION - | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; - - // Boolean extra used to inform the editor that it may want to customize the editing experience - // for the sharesheet editing flow. - private static final String EDIT_SOURCE = "edit_source"; - private static final String EDIT_SOURCE_SHARESHEET = "sharesheet"; - - private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; - private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; - - private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; - - private final Context mContext; - - @Nullable private Runnable mCopyButtonRunnable; - private Runnable mEditButtonRunnable; - private final ImmutableList<ChooserAction> mCustomActions; - @Nullable private final ChooserAction mModifyShareAction; - private final Consumer<Boolean> mExcludeSharedTextAction; - @Nullable private final ShareResultSender mShareResultSender; - private final Consumer</* @Nullable */ Integer> mFinishCallback; - private final EventLog mLog; - - /** - * @param context - * @param imageEditor an explicit Activity to launch for editing images - * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" - * setting is updated. The argument is whether the shared text is to be excluded. - * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image - * View in the Sharesheet UI, if any, or null. - * @param activityStarter a delegate to launch activities when actions are selected. - * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was - * completed). - */ - public ChooserActionFactory( - Context context, - Intent targetIntent, - String referrerPackageName, - List<ChooserAction> chooserActions, - @Nullable ChooserAction modifyShareAction, - Optional<ComponentName> imageEditor, - EventLog log, - Consumer<Boolean> onUpdateSharedTextIsExcluded, - Callable</* @Nullable */ View> firstVisibleImageQuery, - ActionActivityStarter activityStarter, - @Nullable ShareResultSender shareResultSender, - Consumer</* @Nullable */ Integer> finishCallback, - ClipboardManager clipboardManager) { - this( - context, - makeCopyButtonRunnable( - clipboardManager, - targetIntent, - referrerPackageName, - finishCallback, - log), - makeEditButtonRunnable( - getEditSharingTarget( - context, - targetIntent, - imageEditor), - firstVisibleImageQuery, - activityStarter, - log), - chooserActions, - modifyShareAction, - onUpdateSharedTextIsExcluded, - log, - shareResultSender, - finishCallback); - - } - - @VisibleForTesting - ChooserActionFactory( - Context context, - @Nullable Runnable copyButtonRunnable, - Runnable editButtonRunnable, - List<ChooserAction> customActions, - @Nullable ChooserAction modifyShareAction, - Consumer<Boolean> onUpdateSharedTextIsExcluded, - EventLog log, - @Nullable ShareResultSender shareResultSender, - Consumer</* @Nullable */ Integer> finishCallback) { - mContext = context; - mCopyButtonRunnable = copyButtonRunnable; - mEditButtonRunnable = editButtonRunnable; - mCustomActions = ImmutableList.copyOf(customActions); - mModifyShareAction = modifyShareAction; - mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; - mLog = log; - mShareResultSender = shareResultSender; - mFinishCallback = finishCallback; - - if (mShareResultSender != null) { - mEditButtonRunnable = () -> { - mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); - editButtonRunnable.run(); - }; - if (mCopyButtonRunnable != null) { - mCopyButtonRunnable = () -> { - mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); - copyButtonRunnable.run(); - }; - } - } - } - - @Override - @Nullable - public Runnable getEditButtonRunnable() { - return mEditButtonRunnable; - } - - @Override - @Nullable - public Runnable getCopyButtonRunnable() { - return mCopyButtonRunnable; - } - - /** Create custom actions */ - @Override - public List<ActionRow.Action> createCustomActions() { - List<ActionRow.Action> actions = new ArrayList<>(); - for (int i = 0; i < mCustomActions.size(); i++) { - final int position = i; - ActionRow.Action actionRow = createCustomAction( - mCustomActions.get(i), () -> logCustomAction(position)); - if (actionRow != null) { - actions.add(actionRow); - } - } - return actions; - } - - /** - * Provides a share modification action, if any. - */ - @Override - @Nullable - public ActionRow.Action getModifyShareAction() { - return createCustomAction(mModifyShareAction, this::logModifyShareAction); - } - - /** - * <p> - * Creates an exclude-text action that can be called when the user changes shared text - * status in the Media + Text preview. - * </p> - * <p> - * <code>true</code> argument value indicates that the text should be excluded. - * </p> - */ - @Override - public Consumer<Boolean> getExcludeSharedTextAction() { - return mExcludeSharedTextAction; - } - - @Nullable - private static Runnable makeCopyButtonRunnable( - ClipboardManager clipboardManager, - Intent targetIntent, - String referrerPackageName, - Consumer<Integer> finishCallback, - EventLog log) { - final ClipData clipData; - try { - clipData = extractTextToCopy(targetIntent); - } catch (Throwable t) { - Log.e(TAG, "Failed to extract data to copy", t); - return null; - } - if (clipData == null) { - return null; - } - return () -> { - clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); - - log.logActionSelected(EventLog.SELECTION_TYPE_COPY); - finishCallback.accept(Activity.RESULT_OK); - }; - } - - @Nullable - private static ClipData extractTextToCopy(Intent targetIntent) { - if (targetIntent == null) { - return null; - } - - final String action = targetIntent.getAction(); - - ClipData clipData = null; - if (Intent.ACTION_SEND.equals(action)) { - String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); - - if (extraText != null) { - clipData = ClipData.newPlainText(null, extraText); - } else { - Log.w(TAG, "No data available to copy to clipboard"); - } - } else { - // expected to only be visible with ACTION_SEND (when a text is shared) - Log.d(TAG, "Action (" + action + ") not supported for copying to clipboard"); - } - return clipData; - } - - private static TargetInfo getEditSharingTarget( - Context context, - Intent originalIntent, - Optional<ComponentName> imageEditor) { - - final Intent resolveIntent = new Intent(originalIntent); - // Retain only URI permission grant flags if present. Other flags may prevent the scene - // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, - // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. - resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); - imageEditor.ifPresent(resolveIntent::setComponent); - resolveIntent.setAction(Intent.ACTION_EDIT); - resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); - String originalAction = originalIntent.getAction(); - if (Intent.ACTION_SEND.equals(originalAction)) { - if (resolveIntent.getData() == null) { - Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - String mimeType = context.getContentResolver().getType(uri); - resolveIntent.setDataAndType(uri, mimeType); - } - } - } else { - Log.e(TAG, originalAction + " is not supported."); - return null; - } - final ResolveInfo ri = context.getPackageManager().resolveActivity( - resolveIntent, PackageManager.GET_META_DATA); - if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); - return null; - } - - final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ri, - context.getString(R.string.screenshot_edit), - "", - resolveIntent); - dri.getDisplayIconHolder().setDisplayIcon( - context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); - return dri; - } - - private static Runnable makeEditButtonRunnable( - TargetInfo editSharingTarget, - Callable</* @Nullable */ View> firstVisibleImageQuery, - ActionActivityStarter activityStarter, - EventLog log) { - return () -> { - // Log share completion via edit. - log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); - - View firstImageView = null; - try { - firstImageView = firstVisibleImageQuery.call(); - } catch (Exception e) { /* ignore */ } - // Action bar is user-independent; always start as primary. - if (firstImageView == null) { - activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); - } else { - activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( - editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); - } - }; - } - - @Nullable - ActionRow.Action createCustomAction(@Nullable ChooserAction action, Runnable loggingRunnable) { - if (action == null) { - return null; - } - Drawable icon = action.getIcon().loadDrawable(mContext); - if (icon == null && TextUtils.isEmpty(action.getLabel())) { - return null; - } - return new ActionRow.Action( - action.getLabel(), - icon, - () -> { - try { - action.getAction().send( - null, - 0, - null, - null, - null, - null, - ActivityOptions.makeCustomAnimation( - mContext, - R.anim.slide_in_right, - R.anim.slide_out_left) - .toBundle()); - } catch (PendingIntent.CanceledException e) { - Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); - } - if (loggingRunnable != null) { - loggingRunnable.run(); - } - if (mShareResultSender != null) { - mShareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); - } - mFinishCallback.accept(Activity.RESULT_OK); - } - ); - } - - void logCustomAction(int position) { - mLog.logCustomActionSelected(position); - } - - private void logModifyShareAction() { - mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); - } -} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java deleted file mode 100644 index 8387212a..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ /dev/null @@ -1,2637 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import static android.app.VoiceInteractor.PickOptionRequest.Option; -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.content.Intent.FLAG_ACTIVITY_NEW_TASK; -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 static androidx.lifecycle.LifecycleKt.getCoroutineScope; - -import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION; -import static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; -import static com.android.intentresolver.v2.ui.model.ActivityModel.ACTIVITY_MODEL_KEY; -import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; -import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; - -import static java.util.Collections.emptyList; -import static java.util.Objects.requireNonNull; -import static java.util.Objects.requireNonNullElse; - -import android.app.ActivityManager; -import android.app.ActivityOptions; -import android.app.ActivityThread; -import android.app.VoiceInteractor; -import android.app.admin.DevicePolicyEventLogger; -import android.app.prediction.AppPredictor; -import android.app.prediction.AppTarget; -import android.app.prediction.AppTargetEvent; -import android.app.prediction.AppTargetId; -import android.content.ClipboardManager; -import android.content.ComponentName; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.IntentSender; -import android.content.SharedPreferences; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.content.pm.ShortcutInfo; -import android.content.pm.UserInfo; -import android.content.res.Configuration; -import android.database.Cursor; -import android.graphics.Insets; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.StrictMode; -import android.os.SystemClock; -import android.os.Trace; -import android.os.UserHandle; -import android.os.UserManager; -import android.service.chooser.ChooserTarget; -import android.stats.devicepolicy.DevicePolicyEnums; -import android.text.TextUtils; -import android.util.Log; -import android.util.Slog; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewGroup.LayoutParams; -import android.view.ViewTreeObserver; -import android.view.Window; -import android.view.WindowInsets; -import android.view.WindowManager; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TabHost; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.MainThread; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.ViewModelProvider; -import androidx.lifecycle.viewmodel.CreationExtras; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager.widget.ViewPager; - -import com.android.intentresolver.AnnotatedUserHandles; -import com.android.intentresolver.ChooserGridLayoutManager; -import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.ChooserRefinementManager; -import com.android.intentresolver.ChooserStackedAppDialogFragment; -import com.android.intentresolver.ChooserTargetActionsDialogFragment; -import com.android.intentresolver.EnterTransitionAnimationDelegate; -import com.android.intentresolver.FeatureFlags; -import com.android.intentresolver.IntentForwarderActivity; -import com.android.intentresolver.PackagesChangedListener; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.ResolverViewPager; -import com.android.intentresolver.StartsSelectedItem; -import com.android.intentresolver.WorkProfileAvailabilityManager; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.MultiDisplayResolveInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.BasePreviewViewModel; -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; -import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; -import com.android.intentresolver.contentpreview.PayloadToggleInteractor; -import com.android.intentresolver.contentpreview.PreviewViewModel; -import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.TargetDataLoader; -import com.android.intentresolver.logging.EventLog; -import com.android.intentresolver.measurements.Tracer; -import com.android.intentresolver.model.AbstractResolverComparator; -import com.android.intentresolver.model.AppPredictionServiceResolverComparator; -import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; -import com.android.intentresolver.shortcuts.AppPredictorFactory; -import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.intentresolver.v2.data.repository.DevicePolicyResources; -import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; -import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; -import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; -import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; -import com.android.intentresolver.v2.platform.AppPredictionAvailable; -import com.android.intentresolver.v2.platform.ImageEditor; -import com.android.intentresolver.v2.platform.NearbyShare; -import com.android.intentresolver.v2.profiles.ChooserMultiProfilePagerAdapter; -import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter; -import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType; -import com.android.intentresolver.v2.profiles.OnProfileSelectedListener; -import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.v2.profiles.TabConfig; -import com.android.intentresolver.v2.ui.ActionTitle; -import com.android.intentresolver.v2.ui.ShareResultSender; -import com.android.intentresolver.v2.ui.ShareResultSenderFactory; -import com.android.intentresolver.v2.ui.model.ActivityModel; -import com.android.intentresolver.v2.ui.model.ChooserRequest; -import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel; -import com.android.intentresolver.widget.ImagePreviewView; -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.MetricsEvent; -import com.android.internal.util.LatencyTracker; - -import com.google.common.collect.ImmutableList; - -import dagger.hilt.android.AndroidEntryPoint; - -import kotlin.Pair; -import kotlin.Unit; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Consumer; - -import javax.inject.Inject; - -/** - * The Chooser Activity handles intent resolution specifically for sharing intents - - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. - * - */ -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@AndroidEntryPoint(FragmentActivity.class) -public class ChooserActivity extends Hilt_ChooserActivity implements - ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem { - private static final String TAG = "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 - * ourselves when onStop gets called. - */ - 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. - * @hide - */ - public static final String FIRST_IMAGE_PREVIEW_TRANSITION_NAME = "screenshot_preview_image"; - - private static final boolean DEBUG = true; - - public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; - private static final String SHORTCUT_TARGET = "shortcut_target"; - - ////////////////////////////////////////////////////////////////////////////////////////////// - // Inherited properties. - ////////////////////////////////////////////////////////////////////////////////////////////// - private static final String TAB_TAG_PERSONAL = "personal"; - private static final String TAB_TAG_WORK = "work"; - - private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; - protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; - - private int mLayoutId; - private UserHandle mHeaderCreatorUser; - protected static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; - protected static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; - private boolean mRegistered; - private PackageMonitor mPersonalPackageMonitor; - private PackageMonitor mWorkPackageMonitor; - protected View mProfileView; - - protected ActivityLogic mLogic; - protected ResolverDrawerLayout mResolverDrawerLayout; - protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; - protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - - /** See {@link #setRetainInOnStop}. */ - private boolean mRetainInOnStop; - protected Insets mSystemWindowInsets = null; - private ResolverActivity.PickTargetOptionRequest mPickOptionRequest; - - @Nullable - private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - - ////////////////////////////////////////////////////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////////////////////// - - - // 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<>(); - - private static final int TARGET_TYPE_DEFAULT = 0; - private static final int TARGET_TYPE_CHOOSER_TARGET = 1; - private static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; - private static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; - - 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; - - @Inject public ChooserHelper mChooserHelper; - @Inject public FeatureFlags mFeatureFlags; - @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; - @Inject public EventLog mEventLog; - @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; - @Inject @ImageEditor public Optional<ComponentName> mImageEditor; - @Inject @NearbyShare public Optional<ComponentName> mNearbyShare; - @Inject public TargetDataLoader mTargetDataLoader; - @Inject public DevicePolicyResources mDevicePolicyResources; - @Inject public PackageManager mPackageManager; - @Inject public ClipboardManager mClipboardManager; - @Inject public IntentForwarding mIntentForwarding; - @Inject public ShareResultSenderFactory mShareResultSenderFactory; - @Nullable - private ShareResultSender mShareResultSender; - - private ChooserRefinementManager mRefinementManager; - - private ChooserContentPreviewUi mChooserContentPreviewUi; - - private boolean mShouldDisplayLandscape; - private long mChooserShownTime; - protected boolean mIsSuccessfullySelected; - - private int mCurrAvailableWidth = 0; - private Insets mLastAppliedInsets = null; - private int mLastNumberOfChildren = -1; - private int mMaxTargetsPerRow = 1; - - 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"; - - private final ExecutorService mBackgroundThreadPoolExecutor = Executors.newFixedThreadPool(5); - - private int mScrollStatus = SCROLL_STATUS_IDLE; - - private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = - new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); - - private final View mContentView = null; - - private final Map<Integer, ProfileRecord> mProfileRecords = new HashMap<>(); - - private boolean mExcludeSharedText = false; - /** - * When we intend to finish the activity with a shared element transition, we can't immediately - * finish() when the transition is invoked, as the receiving end may not be able to start the - * animation and the UI breaks if this takes too long. Instead we defer finishing until onStop - * in order to wait for the transition to begin. - */ - private boolean mFinishWhenStopped = false; - - private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); - - protected ActivityModel createActivityModel() { - return ActivityModel.createFrom(this); - } - - private ChooserViewModel mViewModel; - private ActivityModel mActivityModel; - - @VisibleForTesting - protected ChooserActivityLogic createActivityLogic() { - return new ChooserActivityLogic( - TAG, - /* activity = */ this, - this::onWorkProfileStatusUpdated); - } - - @NonNull - @Override - public CreationExtras getDefaultViewModelCreationExtras() { - return addDefaultArgs( - super.getDefaultViewModelCreationExtras(), - new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel())); - } - - @Override - protected final void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Log.i(TAG, "onCreate"); - mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); - mActivityModel = mViewModel.getActivityModel(); - - int callerUid = mActivityModel.getLaunchedFromUid(); - if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { - Log.e(TAG, "Can't start a resolver from uid " + callerUid); - finish(); - } - - setTheme(R.style.Theme_DeviceDefault_Chooser); - Tracer.INSTANCE.markLaunched(); - if (!mViewModel.init()) { - finish(); - return; - } - - // The post-create callback is invoked when this function returns, via Lifecycle. - mChooserHelper.setPostCreateCallback(this::init); - - IntentSender chosenComponentSender = - mViewModel.getChooserRequest().getChosenComponentSender(); - if (chosenComponentSender != null) { - mShareResultSender = mShareResultSenderFactory - .create(mActivityModel.getLaunchedFromUid(), chosenComponentSender); - } - mLogic = createActivityLogic(); - } - - @Override - protected final void onStart() { - super.onStart(); - - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (hasWorkProfile()) { - mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); - } - } - - @Override - protected final void onResume() { - super.onResume(); - Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); - mFinishWhenStopped = false; - mRefinementManager.onActivityResume(); - } - - @Override - protected final void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mRetainInOnStop) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this); - - if (mRefinementManager != null) { - mRefinementManager.onActivityStop(isChangingConfigurations()); - } - - if (mFinishWhenStopped) { - mFinishWhenStopped = false; - finish(); - } - } - - @Override - protected final void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - - @Override - protected final void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, - false); - if (hasWorkProfile()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mChooserMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, - false); - } - mRegistered = true; - } - WorkProfileAvailabilityManager workProfileAvailabilityManager = - mLogic.getWorkProfileAvailabilityManager(); - if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { - if (workProfileAvailabilityManager.isQuietModeEnabled()) { - workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); - } - } - mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - } - - @Override - protected final void onDestroy() { - super.onDestroy(); - - if (isFinishing()) { - mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); - } - - mBackgroundThreadPoolExecutor.shutdownNow(); - - destroyProfileRecords(); - } - - private void init() { - mIntentReceivedTime.set(System.currentTimeMillis()); - mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); - - mPinnedSharedPrefs = getPinnedSharedPrefs(this); - mMaxTargetsPerRow = - getResources().getInteger(R.integer.config_chooser_max_targets_per_row); - mShouldDisplayLandscape = - shouldDisplayLandscape(getResources().getConfiguration().orientation); - - ChooserRequest chooserRequest = mViewModel.getChooserRequest(); - setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); - createProfileRecords( - new AppPredictorFactory( - this, - Objects.toString(chooserRequest.getSharedText(), null), - chooserRequest.getShareTargetFilter(), - mAppPredictionAvailable - ), - chooserRequest.getShareTargetFilter() - ); - - Intent intent = mViewModel.getChooserRequest().getTargetIntent(); - List<Intent> initialIntents = mViewModel.getChooserRequest().getInitialIntents(); - - mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), - /* resolutionList = */ null, - false - ); - if (!configureContentView(mTargetDataLoader)) { - mPersonalPackageMonitor = createPackageMonitor( - mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); - mPersonalPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, - false - ); - if (hasWorkProfile()) { - mWorkPackageMonitor = createPackageMonitor( - mChooserMultiProfilePagerAdapter.getWorkListAdapter()); - mWorkPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, - false - ); - } - mRegistered = true; - final ResolverDrawerLayout rdl = findViewById( - com.android.internal.R.id.contentPanel); - if (rdl != null) { - rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { - @Override - public void onDismissed() { - finish(); - } - }); - - boolean hasTouchScreen = mPackageManager - .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); - - if (isVoiceInteraction() || !hasTouchScreen) { - rdl.setCollapsed(false); - } - - rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); - - mResolverDrawerLayout = rdl; - } - final Set<String> categories = intent.getCategories(); - MetricsLogger.action(this, - mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() - ? MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED - : MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, - intent.getAction() + ":" + intent.getType() + ":" - + (categories != null ? Arrays.toString(categories.toArray()) - : "")); - } - - getEventLog().logSharesheetTriggered(); - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); - mRefinementManager.getRefinementCompletion().observe(this, completion -> { - if (completion.consume()) { - TargetInfo targetInfo = completion.getTargetInfo(); - // targetInfo is non-null if the refinement process was successful. - if (targetInfo != null) { - maybeRemoveSharedText(targetInfo); - - // We already block suspended targets from going to refinement, and we probably - // can't recover a Chooser session if that's the reason the refined target fails - // to launch now. Fire-and-forget the refined launch; ignore the return value - // and just make sure the Sharesheet session gets cleaned up regardless. - final ResolveInfo ri = targetInfo.getResolveInfo(); - final Intent intent1 = targetInfo.getResolvedIntent(); - - safelyStartActivity(targetInfo); - - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - targetInfo.isSuspended(); - } - - finish(); - } - }); - BasePreviewViewModel previewViewModel = - new ViewModelProvider(this, createPreviewViewModelFactory()) - .get(BasePreviewViewModel.class); - previewViewModel.init( - chooserRequest.getTargetIntent(), - mActivityModel.getIntent(), - chooserRequest.getAdditionalContentUri(), - chooserRequest.getFocusedItemPosition(), - mChooserServiceFeatureFlags.chooserPayloadToggling()); - ChooserActionFactory chooserActionFactory = createChooserActionFactory(); - ChooserContentPreviewUi.ActionFactory actionFactory = chooserActionFactory; - if (previewViewModel.getPreviewDataProvider().getPreviewType() - == CONTENT_PREVIEW_PAYLOAD_SELECTION - && mChooserServiceFeatureFlags.chooserPayloadToggling()) { - PayloadToggleInteractor payloadToggleInteractor = - previewViewModel.getPayloadToggleInteractor(); - if (payloadToggleInteractor != null) { - ChooserMutableActionFactory mutableActionFactory = - new ChooserMutableActionFactory(chooserActionFactory); - actionFactory = mutableActionFactory; - JavaFlowHelper.collect( - getCoroutineScope(getLifecycle()), - payloadToggleInteractor.getCustomActions(), - mutableActionFactory::updateCustomActions); - } - } - mChooserContentPreviewUi = new ChooserContentPreviewUi( - getCoroutineScope(getLifecycle()), - previewViewModel.getPreviewDataProvider(), - chooserRequest.getTargetIntent(), - previewViewModel.getImageLoader(), - actionFactory, - mEnterTransitionAnimationDelegate, - new HeadlineGeneratorImpl(this), - chooserRequest.getContentTypeHint(), - chooserRequest.getMetadataText(), - mChooserServiceFeatureFlags.chooserPayloadToggling()); - updateStickyContentPreview(); - if (shouldShowStickyContentPreview() - || mChooserMultiProfilePagerAdapter - .getCurrentRootAdapter().getSystemRowCount() != 0) { - getEventLog().logActionShareWithPreview( - mChooserContentPreviewUi.getPreferredContentPreview()); - } - mChooserShownTime = System.currentTimeMillis(); - final long systemCost = mChooserShownTime - mIntentReceivedTime.get(); - getEventLog().logChooserActivityShown( - isWorkProfile(), chooserRequest.getTargetType(), systemCost); - if (mResolverDrawerLayout != null) { - mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); - - mResolverDrawerLayout.setOnCollapsedChangedListener( - isCollapsed -> { - mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed); - getEventLog().logSharesheetExpansionChanged(isCollapsed); - }); - } - if (DEBUG) { - Log.d(TAG, "System Time Cost is " + systemCost); - } - getEventLog().logShareStarted( - chooserRequest.getReferrerPackage(), - chooserRequest.getTargetType(), - chooserRequest.getCallerChooserTargets().size(), - chooserRequest.getInitialIntents().size(), - isWorkProfile(), - mChooserContentPreviewUi.getPreferredContentPreview(), - chooserRequest.getTargetAction(), - chooserRequest.getChooserActions().size(), - chooserRequest.getModifyShareAction() != null - ); - mEnterTransitionAnimationDelegate.postponeTransition(); - } - - private void restore(@Nullable Bundle savedInstanceState) { - if (savedInstanceState != null) { - // onRestoreInstanceState - //resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); - } - } - - mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - - ////////////////////////////////////////////////////////////////////////////////////////////// - // Inherited methods - ////////////////////////////////////////////////////////////////////////////////////////////// - - private boolean isAutolaunching() { - return !mRegistered && isFinishing(); - } - - private boolean maybeAutolaunchIfSingleTarget() { - int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - if (count != 1) { - return false; - } - - if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { - return false; - } - - // Only one target, so we're a candidate to auto-launch! - final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(0, false); - if (shouldAutoLaunchSingleChoice(target)) { - safelyStartActivity(target); - finish(); - return true; - } - return false; - } - - - private boolean isTwoPagePersonalAndWorkConfiguration() { - return (mChooserMultiProfilePagerAdapter.getCount() == 2) - && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) - && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); - } - - /** - * When we have a personal and a work profile, we auto launch in the following scenario: - * - There is 1 resolved target on each profile - * - That target is the same app on both profiles - * - The target app has permission to communicate cross profiles - * - The target app has declared it supports cross-profile communication via manifest metadata - */ - private boolean maybeAutolaunchIfCrossProfileSupported() { - if (!isTwoPagePersonalAndWorkConfiguration()) { - return false; - } - - ResolverListAdapter activeListAdapter = - (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mChooserMultiProfilePagerAdapter.getPersonalListAdapter() - : mChooserMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter inactiveListAdapter = - (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mChooserMultiProfilePagerAdapter.getWorkListAdapter() - : mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); - - if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { - return false; - } - - if ((activeListAdapter.getUnfilteredCount() != 1) - || (inactiveListAdapter.getUnfilteredCount() != 1)) { - return false; - } - - TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); - TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals( - activeProfileTarget.getResolvedComponentName(), - inactiveProfileTarget.getResolvedComponentName())) { - return false; - } - - if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { - return false; - } - - String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { - return false; - } - - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) - .setBoolean(activeListAdapter.getUserHandle() - .equals(requireAnnotatedUserHandles().personalProfileUserHandle)) - .setStrings(getMetricsCategory()) - .write(); - safelyStartActivity(activeProfileTarget); - finish(); - return true; - } - - /** - * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} - */ - private boolean maybeAutolaunchActivity() { - int numberOfProfiles = mChooserMultiProfilePagerAdapter.getItemCount(); - // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the - // correct intent-picker UIs (e.g., mini-resolver) if it was launched without - // ACTION_SEND. - if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { - return true; - } else if (maybeAutolaunchIfCrossProfileSupported()) { - return true; - } - return false; - } - - @Override // ResolverListCommunicator - public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, - boolean rebuildCompleted) { - if (isAutolaunching()) { - return; - } - if (mChooserMultiProfilePagerAdapter - .shouldShowEmptyStateScreen((ChooserListAdapter) listAdapter)) { - mChooserMultiProfilePagerAdapter - .showEmptyResolverListEmptyState((ChooserListAdapter) listAdapter); - } else { - mChooserMultiProfilePagerAdapter.showListView((ChooserListAdapter) listAdapter); - } - // showEmptyResolverListEmptyState can mark the tab as loaded, - // which is a precondition for auto launching - if (rebuildCompleted && maybeAutolaunchActivity()) { - return; - } - if (doPostProcessing) { - maybeCreateHeader(listAdapter); - onListRebuilt(listAdapter, rebuildCompleted); - } - } - - private CharSequence getOrLoadDisplayLabel(TargetInfo info) { - if (info.isDisplayResolveInfo()) { - mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); - } - CharSequence displayLabel = info.getDisplayLabel(); - return displayLabel == null ? "" : displayLabel; - } - - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { - final ActionTitle title = ActionTitle.forAction(intent.getAction()); - - // While there may already be a filtered item, we can only use it in the title if the list - // is already sorted and all information relevant to it is already in the list. - final boolean named = - mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; - if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { - return getString(defaultTitleRes); - } else { - return named - ? getString( - title.namedTitleRes, - getOrLoadDisplayLabel( - mChooserMultiProfilePagerAdapter - .getActiveListAdapter().getFilteredItem())) - : getString(title.titleRes); - } - } - - /** - * Configure the area above the app selection list (title, content preview, etc). - */ - private void maybeCreateHeader(ResolverListAdapter listAdapter) { - if (mHeaderCreatorUser != null - && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { - return; - } - if (!hasWorkProfile() - && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setVisibility(View.GONE); - } - } - - CharSequence title = mViewModel.getChooserRequest().getTitle() != null - ? mViewModel.getChooserRequest().getTitle() - : getTitleForAction(mViewModel.getChooserRequest().getTargetIntent(), - mViewModel.getChooserRequest().getDefaultTitleResource()); - - if (!TextUtils.isEmpty(title)) { - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setText(title); - } - setTitle(title); - } - - final ImageView iconView = findViewById(com.android.internal.R.id.icon); - if (iconView != null) { - listAdapter.loadFilteredItemIconTaskAsync(iconView); - } - mHeaderCreatorUser = listAdapter.getUserHandle(); - } - - /** Start the activity specified by the {@link TargetInfo}.*/ - public final void safelyStartActivity(TargetInfo cti) { - // In case cloned apps are present, we would want to start those apps in cloned user - // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle - // identifies the correct user space in such cases. - UserHandle activityUserHandle = cti.getResolveInfo().userHandle; - safelyStartActivityAsUser(cti, activityUserHandle, null); - } - - protected final void safelyStartActivityAsUser( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // We're dispatching intents that might be coming from legacy apps, so - // don't kill ourselves. - StrictMode.disableDeathOnFileUriExposure(); - try { - safelyStartActivityInternal(cti, user, options); - } finally { - StrictMode.enableDeathOnFileUriExposure(); - } - } - - @VisibleForTesting - protected void safelyStartActivityInternal( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // If the target is suspended, the activity will not be successfully launched. - // Do not unregister from package manager updates in this case - if (!cti.isSuspended() && mRegistered) { - if (mPersonalPackageMonitor != null) { - mPersonalPackageMonitor.unregister(); - } - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - // If needed, show that intent is forwarded - // from managed profile to owner or other way around. - String profileSwitchMessage = mIntentForwarding.forwardMessageFor( - mViewModel.getChooserRequest().getTargetIntent()); - if (profileSwitchMessage != null) { - Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); - } - try { - if (cti.startAsCaller(this, options, user.getIdentifier())) { - maybeSendShareResult(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - } catch (RuntimeException e) { - Slog.wtf(TAG, - "Unable to launch as uid " + mActivityModel.getLaunchedFromUid() - + " package " + mActivityModel.getLaunchedFromPackage() + - ", while running in " + ActivityThread.currentProcessName(), e); - } - } - - private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { - return; - } - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean( - currentUserHandle.equals( - requireAnnotatedUserHandles().personalProfileUserHandle)) - .setStrings(getMetricsCategory(), - cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") - .write(); - } - - private boolean hasWorkProfile() { - return requireAnnotatedUserHandles().workProfileUserHandle != null; - } - private LatencyTracker getLatencyTracker() { - return LatencyTracker.getInstance(this); - } - - /** - * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets - * called and we are launched in a new task. - */ - protected final void setRetainInOnStop(boolean retainInOnStop) { - mRetainInOnStop = retainInOnStop; - } - - // @NonFinalForTesting - @VisibleForTesting - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - return new CrossProfileIntentsChecker(getContentResolver()); - } - - protected final EmptyStateProvider createEmptyStateProvider( - @Nullable UserHandle workProfileUserHandle) { - final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); - - final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mLogic.getWorkProfileAvailabilityManager(), - /* onSwitchOnWorkSelectedListener= */ - () -> { - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - }, - getMetricsCategory()); - - final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( - this, - workProfileUserHandle, - requireAnnotatedUserHandles().personalProfileUserHandle, - getMetricsCategory(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch - ); - - // Return composite provider, the order matters (the higher, the more priority) - return new CompositeEmptyStateProvider( - blockerEmptyStateProvider, - workProfileOffEmptyStateProvider, - noAppsEmptyStateProvider - ); - } - - private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { - try { - ApplicationInfo appInfo = mPackageManager.getApplicationInfo( - resolveInfo.activityInfo.packageName, 0 /* default flags */); - return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; - } catch (PackageManager.NameNotFoundException e) { - return false; - } - } - - private boolean hasManagedProfile() { - UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - if (userManager == null) { - return false; - } - - try { - List<UserInfo> profiles = userManager.getProfiles(getUserId()); - for (UserInfo userInfo : profiles) { - if (userInfo != null && userInfo.isManagedProfile()) { - return true; - } - } - } catch (SecurityException e) { - return false; - } - return false; - } - - /** - * Returns the {@link UserHandle} to use when querying resolutions for intents in a - * {@link ResolverListController} configured for the provided {@code userHandle}. - */ - protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return requireAnnotatedUserHandles().getQueryIntentsUser(userHandle); - } - - protected final boolean isLaunchedAsCloneProfile() { - UserHandle launchUser = requireAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - UserHandle cloneUser = requireAnnotatedUserHandles().cloneProfileUserHandle; - return hasCloneProfile() && launchUser.equals(cloneUser); - } - - private boolean hasCloneProfile() { - return requireAnnotatedUserHandles().cloneProfileUserHandle != null; - } - - /** - * Returns the {@link List} of {@link UserHandle} to pass on to the - * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. - */ - @VisibleForTesting(visibility = PROTECTED) - public final List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) { - return getResolverRankerServiceUserHandleListInternal(userHandle); - } - - - @VisibleForTesting - protected List<UserHandle> getResolverRankerServiceUserHandleListInternal( - UserHandle userHandle) { - List<UserHandle> userList = new ArrayList<>(); - userList.add(userHandle); - // Add clonedProfileUserHandle to the list only if we are: - // a. Building the Personal Tab. - // b. CloneProfile exists on the device. - if (userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) - && hasCloneProfile()) { - userList.add(requireAnnotatedUserHandles().cloneProfileUserHandle); - } - return userList; - } - - /** - * Start activity as a fixed user handle. - * @param cti TargetInfo to be launched. - * @param user User to launch this activity as. - */ - @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) - public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { - safelyStartActivityAsUser(cti, user, null); - } - - protected WindowInsets super_onApplyWindowInsets(View v, WindowInsets insets) { - mSystemWindowInsets = insets.getSystemWindowInsets(); - - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - - // Need extra padding so the list can fully scroll up - // To accommodate for window insets - applyFooterView(mSystemWindowInsets.bottom); - - return insets.consumeSystemWindowInsets(); - } - - @Override // ResolverListCommunicator - public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( - (ChooserListAdapter) listAdapter, - mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile())) { - // We no longer have any items... just finish the activity. - finish(); - } - } - - final Option optionForChooserTarget(TargetInfo target, int index) { - return new Option(getOrLoadDisplayLabel(target), index); - } - - @Override // ResolverListCommunicator - public final void sendVoiceChoicesIfNeeded() { - if (!isVoiceInteraction()) { - // Clearly not needed. - return; - } - - int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount(); - final Option[] options = new Option[count]; - for (int i = 0; i < options.length; i++) { - TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); - if (target == null) { - // If this occurs, a new set of targets is being loaded. Let that complete, - // and have the next call to send voice choices proceed instead. - return; - } - options[i] = optionForChooserTarget(target, i); - } - - mPickOptionRequest = new ResolverActivity.PickTargetOptionRequest( - new VoiceInteractor.Prompt(getTitle()), options, null); - getVoiceInteractor().submitRequest(mPickOptionRequest); - } - - /** - * Sets up the content view. - * @return <code>true</code> if the activity is finishing and creation should halt. - */ - private boolean configureContentView(TargetDataLoader targetDataLoader) { - if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) { - throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " - + "cannot be null."); - } - Trace.beginSection("configureContentView"); - // We partially rebuild the inactive adapter to determine if we should auto launch - // isTabLoaded will be true here if the empty state screen is shown instead of the list. - boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile()); - - mLayoutId = mFeatureFlags.scrollablePreview() - ? R.layout.chooser_grid_scrollable_preview - : R.layout.chooser_grid; - - setContentView(mLayoutId); - mChooserMultiProfilePagerAdapter.setupViewPager( - requireViewById(com.android.internal.R.id.profile_pager)); - boolean result = postRebuildList(rebuildCompleted); - Trace.endSection(); - return result; - } - - /** - * Finishing procedures to be performed after the list has been rebuilt. - * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList. - * @param rebuildCompleted - * @return <code>true</code> if the activity is finishing and creation should halt. - */ - protected boolean postRebuildList(boolean rebuildCompleted) { - return postRebuildListInternal(rebuildCompleted); - } - - /** - * Add a label to signify that the user can pick a different app. - * @param adapter The adapter used to provide data to item views. - */ - public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - final boolean useHeader = adapter.hasFilteredItem(); - if (useHeader) { - FrameLayout stub = findViewById(com.android.internal.R.id.stub); - stub.setVisibility(View.VISIBLE); - TextView textView = (TextView) LayoutInflater.from(this).inflate( - R.layout.resolver_different_item_header, null, false); - if (hasWorkProfile()) { - textView.setGravity(Gravity.CENTER); - } - stub.addView(textView); - } - } - private void setupViewVisibilities() { - ChooserListAdapter activeListAdapter = - mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - if (!mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { - addUseDifferentAppLabelIfNecessary(activeListAdapter); - } - } - /** - * Finishing procedures to be performed after the list has been rebuilt. - * @param rebuildCompleted - * @return <code>true</code> if the activity is finishing and creation should halt. - */ - final boolean postRebuildListInternal(boolean rebuildCompleted) { - int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - - // We only rebuild asynchronously when we have multiple elements to sort. In the case where - // we're already done, we can check if we should auto-launch immediately. - if (rebuildCompleted && maybeAutolaunchActivity()) { - return true; - } - - setupViewVisibilities(); - - if (hasWorkProfile()) { - setupProfileTabs(); - } - - return false; - } - - private void setupProfileTabs() { - TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - - mChooserMultiProfilePagerAdapter.setupProfileTabs( - getLayoutInflater(), - tabHost, - viewPager, - R.layout.resolver_profile_tab_button, - com.android.internal.R.id.profile_pager, - () -> onProfileTabSelected(viewPager.getCurrentItem()), - new OnProfileSelectedListener() { - @Override - public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {} - - @Override - public void onProfilePageStateChanged(int state) { - onHorizontalSwipeStateChanged(state); - } - }); - mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = - tabHost.getTabWidget().getChildAt( - mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }; - } - - public void super_onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - - if (mSystemWindowInsets != null) { - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - } - } - - ////////////////////////////////////////////////////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////////////////////// - - private AnnotatedUserHandles requireAnnotatedUserHandles() { - return requireNonNull(mLogic.getAnnotatedUserHandles()); - } - - private void createProfileRecords( - AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = requireAnnotatedUserHandles().personalProfileUserHandle; - ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); - if (record.shortcutLoader == null) { - Tracer.INSTANCE.endLaunchToShortcutTrace(); - } - - UserHandle workUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; - if (workUserHandle != null) { - createProfileRecord(workUserHandle, targetIntentFilter, factory); - } - } - - private ProfileRecord createProfileRecord( - UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { - AppPredictor appPredictor = factory.create(userHandle); - ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() - ? null - : createShortcutLoader( - this, - appPredictor, - userHandle, - targetIntentFilter, - shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); - ProfileRecord record = new ProfileRecord(appPredictor, shortcutLoader); - mProfileRecords.put(userHandle.getIdentifier(), record); - return record; - } - - @Nullable - private ProfileRecord getProfileRecord(UserHandle userHandle) { - return mProfileRecords.get(userHandle.getIdentifier()); - } - - @VisibleForTesting - protected ShortcutLoader createShortcutLoader( - Context context, - AppPredictor appPredictor, - UserHandle userHandle, - IntentFilter targetIntentFilter, - Consumer<ShortcutLoader.Result> callback) { - return new ShortcutLoader( - context, - getCoroutineScope(getLifecycle()), - appPredictor, - userHandle, - targetIntentFilter, - callback); - } - - static SharedPreferences getPinnedSharedPrefs(Context context) { - return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); - } - - protected ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed) { - if (hasWorkProfile()) { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed); - } else { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed); - } - return mChooserMultiProfilePagerAdapter; - } - - protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = mViewModel.getChooserRequest().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( - requireAnnotatedUserHandles().personalProfileUserHandle, - noWorkToPersonalEmptyState, - noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed) { - ChooserGridAdapter adapter = createChooserGridAdapter( - /* context */ this, - mViewModel.getChooserRequest().getPayloadIntents(), - initialIntents, - rList, - filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle - ); - return new ChooserMultiProfilePagerAdapter( - /* context */ this, - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - adapter)), - createEmptyStateProvider(/* workProfileUserHandle= */ null), - /* workProfileQuietModeChecker= */ () -> false, - /* defaultProfile= */ PROFILE_PERSONAL, - /* workProfileUserHandle= */ null, - requireAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow, - mFeatureFlags); - } - - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed) { - int selectedProfile = findSelectedProfile(); - ChooserGridAdapter personalAdapter = createChooserGridAdapter( - /* context */ this, - mViewModel.getChooserRequest().getPayloadIntents(), - selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle - ); - ChooserGridAdapter workAdapter = createChooserGridAdapter( - /* context */ this, - mViewModel.getChooserRequest().getPayloadIntents(), - selectedProfile == PROFILE_WORK ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle - ); - return new ChooserMultiProfilePagerAdapter( - /* context */ this, - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - personalAdapter), - new TabConfig<>( - PROFILE_WORK, - mDevicePolicyResources.getWorkTabLabel(), - mDevicePolicyResources.getWorkTabAccessibilityLabel(), - TAB_TAG_WORK, - workAdapter)), - createEmptyStateProvider(requireAnnotatedUserHandles().workProfileUserHandle), - () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), - selectedProfile, - requireAnnotatedUserHandles().workProfileUserHandle, - requireAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow, - mFeatureFlags); - } - - private int findSelectedProfile() { - return getProfileForUser(requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - - /** - * 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) - */ - protected boolean isWorkProfile() { - return getSystemService(UserManager.class) - .getUserInfo(UserHandle.myUserId()).isManagedProfile(); - } - - //@Override - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { - return new PackageMonitor() { - @Override - public void onSomePackagesChanged() { - handlePackagesChanged(listAdapter); - } - }; - } - - /** - * Update UI to reflect changes in data. - */ - @Override - public void handlePackagesChanged() { - handlePackagesChanged(/* listAdapter */ null); - } - - /** - * Update UI to reflect changes in data. - * <p>If {@code listAdapter} is {@code null}, both profile list adapters are updated if - * available. - */ - private void handlePackagesChanged(@Nullable ResolverListAdapter listAdapter) { - // Refresh pinned items - mPinnedSharedPrefs = getPinnedSharedPrefs(this); - if (listAdapter == null) { - mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs(); - } else { - listAdapter.handlePackagesChanged(); - } - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super_onConfigurationChanged(newConfig); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager.isLayoutRtl()) { - mChooserMultiProfilePagerAdapter.setupViewPager(viewPager); - } - - mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); - mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); - mChooserMultiProfilePagerAdapter.setMaxTargetsPerRow(mMaxTargetsPerRow); - adjustPreviewWidth(newConfig.orientation, null); - updateStickyContentPreview(); - updateTabPadding(); - } - - private boolean shouldDisplayLandscape(int orientation) { - // Sharesheet fixes the # of items per row and therefore can not correctly lay out - // when in the restricted size of multi-window mode. In the future, would be nice - // to use minimum dp size requirements instead - return orientation == Configuration.ORIENTATION_LANDSCAPE && !isInMultiWindowMode(); - } - - private void adjustPreviewWidth(int orientation, View parent) { - int width = -1; - if (mShouldDisplayLandscape) { - width = getResources().getDimensionPixelSize(R.dimen.chooser_preview_width); - } - - parent = parent == null ? getWindow().getDecorView() : parent; - - updateLayoutWidth(com.android.internal.R.id.content_preview_file_layout, width, parent); - } - - private void updateTabPadding() { - if (hasWorkProfile()) { - View tabs = findViewById(com.android.internal.R.id.tabs); - float iconSize = getResources().getDimension(R.dimen.chooser_icon_size); - // The entire width consists of icons or padding. Divide the item padding in half to get - // paddingHorizontal. - float padding = (tabs.getWidth() - mMaxTargetsPerRow * iconSize) - / mMaxTargetsPerRow / 2; - // Subtract the margin the buttons already have. - padding -= getResources().getDimension(R.dimen.resolver_profile_tab_margin); - tabs.setPadding((int) padding, 0, (int) padding, 0); - } - } - - private void updateLayoutWidth(int layoutResourceId, int width, View parent) { - View view = parent.findViewById(layoutResourceId); - if (view != null && view.getLayoutParams() != null) { - LayoutParams params = view.getLayoutParams(); - params.width = width; - view.setLayoutParams(params); - } - } - - /** - * 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) { - ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( - getResources(), - getLayoutInflater(), - parent, - mFeatureFlags.scrollablePreview() - ? findViewById(R.id.chooser_headline_row_container) - : null); - - if (layout != null) { - adjustPreviewWidth(getResources().getConfiguration().orientation, layout); - } - - return layout; - } - - @Nullable - private View getFirstVisibleImgPreviewView() { - View imagePreview = findViewById(R.id.scrollable_image_preview); - return imagePreview instanceof ImagePreviewView - ? ((ImagePreviewView) imagePreview).getTransitionView() - : null; - } - - /** - * Wrapping the ContentResolver call to expose for easier mocking, - * and to avoid mocking Android core classes. - */ - @VisibleForTesting - public Cursor queryResolver(ContentResolver resolver, Uri uri) { - return resolver.query(uri, null, null, null, null); - } - - private void destroyProfileRecords() { - mProfileRecords.values().forEach(ProfileRecord::destroy); - mProfileRecords.clear(); - } - - @Override // ResolverListCommunicator - public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - ChooserRequest chooserRequest = mViewModel.getChooserRequest(); - - Intent result = defIntent; - if (chooserRequest.getReplacementExtras() != null) { - final Bundle replExtras = - chooserRequest.getReplacementExtras().getBundle(aInfo.packageName); - if (replExtras != null) { - result = new Intent(defIntent); - result.putExtras(replExtras); - } - } - if (aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_PARENT) - || aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_MANAGED_PROFILE)) { - result = Intent.createChooser(result, - getIntent().getCharSequenceExtra(Intent.EXTRA_TITLE)); - - // Don't auto-launch single intents if the intent is being forwarded. This is done - // because automatically launching a resolving application as a response to the user - // action of switching accounts is pretty unexpected. - result.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false); - } - return result; - } - - private void maybeSendShareResult(TargetInfo cti) { - if (mShareResultSender != null) { - final ComponentName target = cti.getResolvedComponentName(); - if (target != null) { - mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo()); - } - } - } - - private void addCallerChooserTargets() { - ChooserRequest chooserRequest = mViewModel.getChooserRequest(); - if (!chooserRequest.getCallerChooserTargets().isEmpty()) { - // Send the caller's chooser targets only to the default profile. - if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( - /* origTarget */ null, - new ArrayList<>(chooserRequest.getCallerChooserTargets()), - TARGET_TYPE_DEFAULT, - /* directShareShortcutInfoCache */ Collections.emptyMap(), - /* directShareAppTargetCache */ Collections.emptyMap()); - } - } - } - - @Override // ResolverListCommunicator - public boolean shouldGetActivityMetadata() { - return true; - } - - public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - if (target.isSuspended()) { - return false; - } - - return mActivityModel.getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, - true); - } - - private void showTargetDetails(TargetInfo targetInfo) { - if (targetInfo == null) return; - - List<DisplayResolveInfo> targetList = targetInfo.getAllDisplayTargets(); - if (targetList.isEmpty()) { - Log.e(TAG, "No displayable data to show target details"); - return; - } - - // TODO: implement these type-conditioned behaviors polymorphically, and consider moving - // the logic into `ChooserTargetActionsDialogFragment.show()`. - boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); - IntentFilter intentFilter; - intentFilter = targetInfo.isSelectableTargetInfo() - ? mViewModel.getChooserRequest().getShareTargetFilter() : null; - String shortcutTitle = targetInfo.isSelectableTargetInfo() - ? targetInfo.getDisplayLabel().toString() : null; - String shortcutIdKey = targetInfo.getDirectShareShortcutId(); - - ChooserTargetActionsDialogFragment.show( - getSupportFragmentManager(), - targetList, - // Adding userHandle from ResolveInfo allows the app icon in Dialog Box to be - // resolved correctly within the same tab. - targetInfo.getResolveInfo().userHandle, - shortcutIdKey, - shortcutTitle, - isShortcutPinned, - intentFilter); - } - - protected boolean onTargetSelected(TargetInfo target) { - if (mRefinementManager.maybeHandleSelection( - target, - mViewModel.getChooserRequest().getRefinementIntentSender(), - getApplication(), - getMainThreadHandler())) { - return false; - } - updateModelAndChooserCounts(target); - maybeRemoveSharedText(target); - safelyStartActivity(target); - - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - return !target.isSuspended(); - } - - @Override - public void startSelected(int which, /* unused */ boolean always, boolean filtered) { - ChooserListAdapter currentListAdapter = - mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - TargetInfo targetInfo = currentListAdapter - .targetInfoForPosition(which, filtered); - if (targetInfo != null && targetInfo.isNotSelectableTargetInfo()) { - return; - } - - final long selectionCost = System.currentTimeMillis() - mChooserShownTime; - - if ((targetInfo != null) && targetInfo.isMultiDisplayResolveInfo()) { - MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; - if (!mti.hasSelected()) { - // Add userHandle based badge to the stackedAppDialogBox. - ChooserStackedAppDialogFragment.show( - getSupportFragmentManager(), - mti, - which, - targetInfo.getResolveInfo().userHandle); - return; - } - } - if (isFinishing()) { - return; - } - - TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(which, filtered); - if (target != null) { - if (onTargetSelected(target)) { - MetricsLogger.action( - this, MetricsEvent.ACTION_APP_DISAMBIG_TAP); - MetricsLogger.action(this, - mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() - ? MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED - : MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); - finish(); - } - } - - // TODO: both of the conditions around this switch logic *should* be redundant, and - // can be removed if certain invariants can be guaranteed. In particular, it seems - // like targetInfo (from `ChooserListAdapter.targetInfoForPosition()`) is *probably* - // expected to be null only at out-of-bounds indexes where `getPositionTargetType()` - // returns TARGET_BAD; then the switch falls through to a default no-op, and we don't - // need to null-check targetInfo. We only need the null check if it's possible that - // the ChooserListAdapter contains null elements "in the middle" of its list data, - // such that they're classified as belonging to one of the real target types. That - // should probably never happen. But why would this method ever be invoked with a - // null target at all? Even an out-of-bounds index should never be "selected"... - if ((currentListAdapter.getCount() > 0) && (targetInfo != null)) { - switch (currentListAdapter.getPositionTargetType(which)) { - case ChooserListAdapter.TARGET_SERVICE: - getEventLog().logShareTargetSelected( - EventLog.SELECTION_TYPE_SERVICE, - targetInfo.getResolveInfo().activityInfo.processName, - which, - /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - mViewModel.getChooserRequest().getCallerChooserTargets().size(), - targetInfo.getHashedTargetIdForMetrics(this), - targetInfo.isPinned(), - mIsSuccessfullySelected, - selectionCost - ); - return; - case ChooserListAdapter.TARGET_CALLER: - case ChooserListAdapter.TARGET_STANDARD: - getEventLog().logShareTargetSelected( - EventLog.SELECTION_TYPE_APP, - targetInfo.getResolveInfo().activityInfo.processName, - (which - currentListAdapter.getSurfacedTargetInfo().size()), - /* directTargetAlsoRanked= */ -1, - currentListAdapter.getCallerTargetCount(), - /* directTargetHashed= */ null, - targetInfo.isPinned(), - mIsSuccessfullySelected, - selectionCost - ); - return; - case ChooserListAdapter.TARGET_STANDARD_AZ: - // 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? - getEventLog().logShareTargetSelected( - EventLog.SELECTION_TYPE_STANDARD, - targetInfo.getResolveInfo().activityInfo.processName, - /* value= */ -1, - /* directTargetAlsoRanked= */ -1, - /* numCallerProvided= */ 0, - /* directTargetHashed= */ null, - /* isPinned= */ false, - mIsSuccessfullySelected, - selectionCost - ); - } - } - } - - private int getRankedPosition(TargetInfo targetInfo) { - String targetPackageName = - targetInfo.getChooserTargetComponentName().getPackageName(); - ChooserListAdapter currentListAdapter = - mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - int maxRankedResults = Math.min( - currentListAdapter.getDisplayResolveInfoCount(), MAX_LOG_RANK_POSITION); - - for (int i = 0; i < maxRankedResults; i++) { - if (currentListAdapter.getDisplayResolveInfo(i) - .getResolveInfo().activityInfo.packageName.equals(targetPackageName)) { - return i; - } - } - return -1; - } - - protected void applyFooterView(int height) { - mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height); - } - - private void logDirectShareTargetReceived(UserHandle forUser) { - ProfileRecord profileRecord = getProfileRecord(forUser); - if (profileRecord == null) { - return; - } - getEventLog().logDirectShareTargetReceived( - MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, - (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime)); - } - - void updateModelAndChooserCounts(TargetInfo info) { - if (info != null && info.isMultiDisplayResolveInfo()) { - info = ((MultiDisplayResolveInfo) info).getSelectedTarget(); - } - if (info != null) { - sendClickToAppPredictor(info); - final ResolveInfo ri = info.getResolveInfo(); - Intent targetIntent = mViewModel.getChooserRequest().getTargetIntent(); - if (ri != null && ri.activityInfo != null && targetIntent != null) { - ChooserListAdapter currentListAdapter = - mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - if (currentListAdapter != null) { - sendImpressionToAppPredictor(info, currentListAdapter); - currentListAdapter.updateModel(info); - currentListAdapter.updateChooserCounts( - ri.activityInfo.packageName, - targetIntent.getAction(), - ri.userHandle); - } - if (DEBUG) { - Log.d(TAG, "ResolveInfo Package is " + ri.activityInfo.packageName); - Log.d(TAG, "Action to be updated is " + targetIntent.getAction()); - } - } else if (DEBUG) { - Log.d(TAG, "Can not log Chooser Counts of null ResolveInfo"); - } - } - mIsSuccessfullySelected = true; - } - - private void maybeRemoveSharedText(@NonNull TargetInfo targetInfo) { - Intent targetIntent = targetInfo.getTargetIntent(); - if (targetIntent == null) { - return; - } - Intent originalTargetIntent = new Intent(mViewModel.getChooserRequest().getTargetIntent()); - // Our TargetInfo implementations add associated component to the intent, let's do the same - // for the sake of the comparison below. - if (targetIntent.getComponent() != null) { - originalTargetIntent.setComponent(targetIntent.getComponent()); - } - // Use filterEquals as a way to check that the primary intent is in use (and not an - // alternative one). For example, an app is sharing an image and a link with mime type - // "image/png" and provides an alternative intent to share only the link with mime type - // "text/uri". Should there be a target that accepts only the latter, the alternative intent - // will be used and we don't want to exclude the link from it. - if (mExcludeSharedText && originalTargetIntent.filterEquals(targetIntent)) { - targetIntent.removeExtra(Intent.EXTRA_TEXT); - } - } - - private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) { - // Send DS target impression info to AppPredictor, only when user chooses app share. - if (targetInfo.isChooserTargetInfo()) { - return; - } - - AppPredictor directShareAppPredictor = getAppPredictor( - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - if (directShareAppPredictor == null) { - return; - } - List<TargetInfo> surfacedTargetInfo = adapter.getSurfacedTargetInfo(); - List<AppTargetId> targetIds = new ArrayList<>(); - for (TargetInfo chooserTargetInfo : surfacedTargetInfo) { - ShortcutInfo shortcutInfo = chooserTargetInfo.getDirectShareShortcutInfo(); - if (shortcutInfo != null) { - ComponentName componentName = - chooserTargetInfo.getChooserTargetComponentName(); - targetIds.add(new AppTargetId( - String.format( - "%s/%s/%s", - shortcutInfo.getId(), - componentName.flattenToString(), - SHORTCUT_TARGET))); - } - } - directShareAppPredictor.notifyLaunchLocationShown(LAUNCH_LOCATION_DIRECT_SHARE, targetIds); - } - - private void sendClickToAppPredictor(TargetInfo targetInfo) { - if (!targetInfo.isChooserTargetInfo()) { - return; - } - - AppPredictor directShareAppPredictor = getAppPredictor( - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); - if (directShareAppPredictor == null) { - return; - } - 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) - .build()); - } - } - - @Nullable - private AppPredictor getAppPredictor(UserHandle userHandle) { - ProfileRecord record = getProfileRecord(userHandle); - // We cannot use APS service when clone profile is present as APS service cannot sort - // cross profile targets as of now. - return ((record == null) || (requireAnnotatedUserHandles().cloneProfileUserHandle != null)) - ? null : record.appPredictor; - } - - protected EventLog getEventLog() { - return mEventLog; - } - - @VisibleForTesting - public ChooserGridAdapter createChooserGridAdapter( - Context context, - List<Intent> payloadIntents, - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - UserHandle userHandle) { - ChooserRequest request = mViewModel.getChooserRequest(); - ChooserListAdapter chooserListAdapter = createChooserListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - request.getTargetIntent(), - request.getReferrerFillInIntent(), - mMaxTargetsPerRow - ); - - return new ChooserGridAdapter( - context, - new ChooserGridAdapter.ChooserActivityDelegate() { - @Override - public boolean shouldShowTabs() { - return hasWorkProfile(); - } - - @Override - public View buildContentPreview(ViewGroup parent) { - return createContentPreviewView(parent); - } - - @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); - // Only a direct share target or an app target is expected - if (longPressedTargetInfo.isDisplayResolveInfo() - || longPressedTargetInfo.isSelectableTargetInfo()) { - showTargetDetails(longPressedTargetInfo); - } - } - }, - chooserListAdapter, - shouldShowContentPreview(), - mMaxTargetsPerRow, - mFeatureFlags); - } - - @VisibleForTesting - public ChooserListAdapter createChooserListAdapter( - Context context, - List<Intent> payloadIntents, - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - ResolverListController resolverListController, - UserHandle userHandle, - Intent targetIntent, - Intent referrerFillInIntent, - int maxTargetsPerRow) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) - ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; - return new ChooserListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - targetIntent, - referrerFillInIntent, - this, - mPackageManager, - getEventLog(), - maxTargetsPerRow, - initialIntentsUserSpace, - mTargetDataLoader, - () -> { - ProfileRecord record = getProfileRecord(userHandle); - if (record != null && record.shortcutLoader != null) { - record.shortcutLoader.reset(); - } - }, - mFeatureFlags); - } - - protected Unit onWorkProfileStatusUpdated() { - UserHandle workUser = requireAnnotatedUserHandles().workProfileUserHandle; - ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); - if (record != null && record.shortcutLoader != null) { - record.shortcutLoader.reset(); - } - if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals( - requireAnnotatedUserHandles().workProfileUserHandle)) { - mChooserMultiProfilePagerAdapter.rebuildActiveTab(true); - } else { - mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - return Unit.INSTANCE; - } - - @VisibleForTesting - protected ChooserListController createListController(UserHandle userHandle) { - AppPredictor appPredictor = getAppPredictor(userHandle); - AbstractResolverComparator resolverComparator; - if (appPredictor != null) { - resolverComparator = new AppPredictionServiceResolverComparator( - this, - mViewModel.getChooserRequest().getTargetIntent(), - mViewModel.getChooserRequest().getLaunchedFromPackage(), - appPredictor, - userHandle, - getEventLog(), - mNearbyShare.orElse(null) - ); - } else { - resolverComparator = - new ResolverRankerServiceResolverComparator( - this, - mViewModel.getChooserRequest().getTargetIntent(), - mViewModel.getChooserRequest().getReferrerPackage(), - null, - getEventLog(), - getResolverRankerServiceUserHandleList(userHandle), - mNearbyShare.orElse(null)); - } - - return new ChooserListController( - this, - mPackageManager, - mViewModel.getChooserRequest().getTargetIntent(), - mViewModel.getChooserRequest().getReferrerPackage(), - requireAnnotatedUserHandles().userIdOfCallingApp, - resolverComparator, - getQueryIntentsUser(userHandle), - mViewModel.getChooserRequest().getFilteredComponentNames(), - mPinnedSharedPrefs); - } - - @VisibleForTesting - protected ViewModelProvider.Factory createPreviewViewModelFactory() { - return PreviewViewModel.Companion.getFactory(); - } - - private ChooserActionFactory createChooserActionFactory() { - ChooserRequest request = mViewModel.getChooserRequest(); - return new ChooserActionFactory( - this, - request.getTargetIntent(), - request.getLaunchedFromPackage(), - request.getChooserActions(), - request.getModifyShareAction(), - mImageEditor, - getEventLog(), - (isExcluded) -> mExcludeSharedText = isExcluded, - this::getFirstVisibleImgPreviewView, - new ChooserActionFactory.ActionActivityStarter() { - @Override - public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { - safelyStartActivityAsUser( - targetInfo, - requireAnnotatedUserHandles().personalProfileUserHandle - ); - finish(); - } - - @Override - public void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( - TargetInfo targetInfo, View sharedElement, String sharedElementName) { - ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( - ChooserActivity.this, sharedElement, sharedElementName); - safelyStartActivityAsUser( - targetInfo, - requireAnnotatedUserHandles().personalProfileUserHandle, - options.toBundle()); - // Can't finish right away because the shared element transition may not - // be ready to start. - mFinishWhenStopped = true; - } - }, - mShareResultSender, - (status) -> { - if (status != null) { - setResult(status); - } - finish(); - }, - mClipboardManager); - } - - /* - * Need to dynamically adjust how many icons can fit per row before we add them, - * which also means setting the correct offset to initially show the content - * preview area + 2 rows of targets - */ - private void handleLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, - int oldTop, int oldRight, int oldBottom) { - if (mChooserMultiProfilePagerAdapter == null) { - return; - } - RecyclerView recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); - ChooserGridAdapter gridAdapter = mChooserMultiProfilePagerAdapter.getCurrentRootAdapter(); - // Skip height calculation if recycler view was scrolled to prevent it inaccurately - // calculating the height, as the logic below does not account for the scrolled offset. - if (gridAdapter == null || recyclerView == null - || recyclerView.computeVerticalScrollOffset() != 0) { - return; - } - - final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight(); - boolean isLayoutUpdated = - gridAdapter.calculateChooserTargetWidth(availableWidth) - || recyclerView.getAdapter() == null - || availableWidth != mCurrAvailableWidth; - - boolean insetsChanged = !Objects.equals(mLastAppliedInsets, mSystemWindowInsets); - - if (isLayoutUpdated - || insetsChanged - || mLastNumberOfChildren != recyclerView.getChildCount()) { - mCurrAvailableWidth = availableWidth; - if (isLayoutUpdated) { - // It is very important we call setAdapter from here. Otherwise in some cases - // the resolver list doesn't get populated, such as b/150922090, b/150918223 - // and b/150936654 - recyclerView.setAdapter(gridAdapter); - ((GridLayoutManager) recyclerView.getLayoutManager()).setSpanCount( - mMaxTargetsPerRow); - - updateTabPadding(); - } - - int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile(); - int initialProfile = findSelectedProfile(); - if (currentProfile != initialProfile) { - return; - } - - if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) { - return; - } - - getMainThreadHandler().post(() -> { - if (mResolverDrawerLayout == null || gridAdapter == null) { - return; - } - int offset = calculateDrawerOffset(top, bottom, recyclerView, gridAdapter); - mResolverDrawerLayout.setCollapsibleHeightReserved(offset); - mEnterTransitionAnimationDelegate.markOffsetCalculated(); - mLastAppliedInsets = mSystemWindowInsets; - }); - } - } - - private int calculateDrawerOffset( - int top, int bottom, RecyclerView recyclerView, ChooserGridAdapter gridAdapter) { - - int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; - int rowsToShow = gridAdapter.getSystemRowCount() - + gridAdapter.getServiceTargetRowCount() - + gridAdapter.getCallerAndRankedTargetRowCount(); - - // then this is most likely not a SEND_* action, so check - // the app target count - if (rowsToShow == 0) { - rowsToShow = gridAdapter.getRowCount(); - } - - // still zero? then use a default height and leave, which - // can happen when there are no targets to show - if (rowsToShow == 0 && !shouldShowStickyContentPreview()) { - offset += getResources().getDimensionPixelSize( - R.dimen.chooser_max_collapsed_height); - return offset; - } - - View stickyContentPreview = findViewById(com.android.internal.R.id.content_preview_container); - if (shouldShowStickyContentPreview() && isStickyContentPreviewShowing()) { - offset += stickyContentPreview.getHeight(); - } - - if (hasWorkProfile()) { - offset += findViewById(com.android.internal.R.id.tabs).getHeight(); - } - - if (recyclerView.getVisibility() == View.VISIBLE) { - rowsToShow = Math.min(4, rowsToShow); - boolean shouldShowExtraRow = shouldShowExtraRow(rowsToShow); - mLastNumberOfChildren = recyclerView.getChildCount(); - for (int i = 0, childCount = recyclerView.getChildCount(); - i < childCount && rowsToShow > 0; i++) { - View child = recyclerView.getChildAt(i); - if (((GridLayoutManager.LayoutParams) - child.getLayoutParams()).getSpanIndex() != 0) { - continue; - } - int height = child.getHeight(); - offset += height; - if (shouldShowExtraRow) { - offset += height; - } - rowsToShow--; - } - } else { - ViewGroup currentEmptyStateView = - mChooserMultiProfilePagerAdapter.getActiveEmptyStateView(); - if (currentEmptyStateView.getVisibility() == View.VISIBLE) { - offset += currentEmptyStateView.getHeight(); - } - } - - return Math.min(offset, bottom - top); - } - - /** - * If we have a tabbed view and are showing 1 row in the current profile and an empty - * state screen in another profile, to prevent cropping of the empty state screen we show - * a second row in the current profile. - */ - private boolean shouldShowExtraRow(int rowsToShow) { - return rowsToShow == 1 - && mChooserMultiProfilePagerAdapter - .shouldShowEmptyStateScreenInAnyInactiveAdapter(); - } - - /** - * Returns {@link #PROFILE_WORK}, if the given user handle matches work user handle. - * Returns {@link #PROFILE_PERSONAL}, otherwise. - **/ - private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(requireAnnotatedUserHandles().workProfileUserHandle)) { - return PROFILE_WORK; - } - // We return personal profile, as it is the default when there is no work profile, personal - // profile represents rootUser, clonedUser & secondaryUser, covering all use cases. - return PROFILE_PERSONAL; - } - - protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { - setupScrollListener(); - maybeSetupGlobalLayoutListener(); - - ChooserListAdapter chooserListAdapter = (ChooserListAdapter) listAdapter; - UserHandle listProfileUserHandle = chooserListAdapter.getUserHandle(); - if (listProfileUserHandle.equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { - mChooserMultiProfilePagerAdapter.getActiveAdapterView() - .setAdapter(mChooserMultiProfilePagerAdapter.getCurrentRootAdapter()); - mChooserMultiProfilePagerAdapter - .setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage()); - } - - //TODO: move this block inside ChooserListAdapter (should be called when - // ResolverListAdapter#mPostListReadyRunnable is executed. - if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { - chooserListAdapter.notifyDataSetChanged(); - } else { - chooserListAdapter.updateAlphabeticalList(); - } - - if (rebuildComplete) { - long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listProfileUserHandle); - if (duration >= 0) { - Log.d(TAG, "app target loading time " + duration + " ms"); - } - addCallerChooserTargets(); - getEventLog().logSharesheetAppLoadComplete(); - maybeQueryAdditionalPostProcessingTargets( - listProfileUserHandle, - chooserListAdapter.getDisplayResolveInfos()); - mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET); - } - } - - private void maybeQueryAdditionalPostProcessingTargets( - UserHandle userHandle, - DisplayResolveInfo[] displayResolveInfos) { - ProfileRecord record = getProfileRecord(userHandle); - if (record == null || record.shortcutLoader == null) { - return; - } - record.loadingStartTime = SystemClock.elapsedRealtime(); - record.shortcutLoader.updateAppTargets(displayResolveInfos); - } - - @MainThread - private void onShortcutsLoaded(UserHandle userHandle, ShortcutLoader.Result result) { - if (DEBUG) { - Log.d(TAG, "onShortcutsLoaded for user: " + userHandle); - } - mDirectShareShortcutInfoCache.putAll(result.getDirectShareShortcutInfoCache()); - mDirectShareAppTargetCache.putAll(result.getDirectShareAppTargetCache()); - ChooserListAdapter adapter = - mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); - if (adapter != null) { - for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) { - adapter.addServiceResults( - resultInfo.getAppTarget(), - resultInfo.getShortcuts(), - result.isFromAppPredictor() - ? TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE - : TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, - mDirectShareShortcutInfoCache, - mDirectShareAppTargetCache); - } - adapter.completeServiceTargetLoading(); - } - - if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { - long duration = Tracer.INSTANCE.endLaunchToShortcutTrace(); - if (duration >= 0) { - Log.d(TAG, "stat to first shortcut time: " + duration + " ms"); - } - } - logDirectShareTargetReceived(userHandle); - sendVoiceChoicesIfNeeded(); - getEventLog().logSharesheetDirectLoadComplete(); - } - - private void setupScrollListener() { - if (mResolverDrawerLayout == null) { - return; - } - int elevatedViewResId = hasWorkProfile() ? - com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; - final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId); - final float defaultElevation = elevatedView.getElevation(); - final float chooserHeaderScrollElevation = - getResources().getDimensionPixelSize(R.dimen.chooser_header_scroll_elevation); - mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( - new RecyclerView.OnScrollListener() { - @Override - public void onScrollStateChanged(RecyclerView view, int scrollState) { - if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { - if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { - mScrollStatus = SCROLL_STATUS_IDLE; - setHorizontalScrollingEnabled(true); - } - } else if (scrollState == RecyclerView.SCROLL_STATE_DRAGGING) { - if (mScrollStatus == SCROLL_STATUS_IDLE) { - mScrollStatus = SCROLL_STATUS_SCROLLING_VERTICAL; - setHorizontalScrollingEnabled(false); - } - } - } - - @Override - public void onScrolled(RecyclerView view, int dx, int dy) { - if (view.getChildCount() > 0) { - View child = view.getLayoutManager().findViewByPosition(0); - if (child == null || child.getTop() < 0) { - elevatedView.setElevation(chooserHeaderScrollElevation); - return; - } - } - - elevatedView.setElevation(defaultElevation); - } - }); - } - - private void maybeSetupGlobalLayoutListener() { - if (hasWorkProfile()) { - return; - } - final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); - recyclerView.getViewTreeObserver() - .addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - // Fixes an issue were the accessibility border disappears on list creation. - recyclerView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setFocusable(true); - titleView.setFocusableInTouchMode(true); - titleView.requestFocus(); - titleView.requestAccessibilityFocus(); - } - } - }); - } - - /** - * 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, - * we instead show the content preview as a regular list item. - */ - private boolean shouldShowStickyContentPreview() { - return shouldShowStickyContentPreviewNoOrientationCheck(); - } - - private boolean shouldShowStickyContentPreviewNoOrientationCheck() { - if (!shouldShowContentPreview()) { - return false; - } - boolean isEmpty = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())).getCount() == 0; - return (mFeatureFlags.scrollablePreview() || hasWorkProfile()) - && (!isEmpty || shouldShowContentPreviewWhenEmpty()); - } - - /** - * This method could be used to override the default behavior when we hide the preview area - * when the current tab doesn't have any items. - * - * @return true if we want to show the content preview area even if the tab for the current - * user is empty - */ - protected boolean shouldShowContentPreviewWhenEmpty() { - return false; - } - - /** - * @return true if we want to show the content preview area - */ - protected boolean shouldShowContentPreview() { - ChooserRequest chooserRequest = mViewModel.getChooserRequest(); - return (chooserRequest != null) && chooserRequest.isSendActionTarget(); - } - - private void updateStickyContentPreview() { - if (shouldShowStickyContentPreviewNoOrientationCheck()) { - // The sticky content preview is only shown when we show the work and personal tabs. - // We don't show it in landscape as otherwise there is no room for scrolling. - // If the sticky content preview will be shown at some point with orientation change, - // then always preload it to avoid subsequent resizing of the share sheet. - ViewGroup contentPreviewContainer = - findViewById(com.android.internal.R.id.content_preview_container); - if (contentPreviewContainer.getChildCount() == 0) { - ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); - contentPreviewContainer.addView(contentPreviewView); - } - } - if (shouldShowStickyContentPreview()) { - showStickyContentPreview(); - } else { - hideStickyContentPreview(); - } - } - - private void showStickyContentPreview() { - if (isStickyContentPreviewShowing()) { - return; - } - ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); - contentPreviewContainer.setVisibility(View.VISIBLE); - } - - private boolean isStickyContentPreviewShowing() { - ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); - return contentPreviewContainer.getVisibility() == View.VISIBLE; - } - - private void hideStickyContentPreview() { - if (!isStickyContentPreviewShowing()) { - return; - } - ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); - contentPreviewContainer.setVisibility(View.GONE); - } - - protected String getMetricsCategory() { - return METRICS_CATEGORY_CHOOSER; - } - - protected void onProfileTabSelected(int currentPage) { - setupViewVisibilities(); - maybeLogProfileChange(); - if (hasWorkProfile()) { - // The device policy logger is only concerned with sessions that include a work profile. - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(currentPage) - .setStrings(getMetricsCategory()) - .write(); - } - - // This fixes an edge case where after performing a variety of gestures, vertical scrolling - // ends up disabled. That's because at some point the old tab's vertical scrolling is - // disabled and the new tab's is enabled. For context, see b/159997845 - setVerticalScrollEnabled(true); - if (mResolverDrawerLayout != null) { - mResolverDrawerLayout.scrollNestedScrollableChildBackToTop(); - } - } - - protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - if (hasWorkProfile()) { - mChooserMultiProfilePagerAdapter - .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); - } - - WindowInsets result = super_onApplyWindowInsets(v, insets); - if (mResolverDrawerLayout != null) { - mResolverDrawerLayout.requestLayout(); - } - return result; - } - - private void setHorizontalScrollingEnabled(boolean enabled) { - ResolverViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSwipingEnabled(enabled); - } - - private void setVerticalScrollEnabled(boolean enabled) { - ChooserGridLayoutManager layoutManager = - (ChooserGridLayoutManager) mChooserMultiProfilePagerAdapter.getActiveAdapterView() - .getLayoutManager(); - layoutManager.setVerticalScrollEnabled(enabled); - } - - void onHorizontalSwipeStateChanged(int state) { - if (state == ViewPager.SCROLL_STATE_DRAGGING) { - if (mScrollStatus == SCROLL_STATUS_IDLE) { - mScrollStatus = SCROLL_STATUS_SCROLLING_HORIZONTAL; - setVerticalScrollEnabled(false); - } - } else if (state == ViewPager.SCROLL_STATE_IDLE) { - if (mScrollStatus == SCROLL_STATUS_SCROLLING_HORIZONTAL) { - mScrollStatus = SCROLL_STATUS_IDLE; - setVerticalScrollEnabled(true); - } - } - } - - protected void maybeLogProfileChange() { - getEventLog().logSharesheetProfileChanged(); - } - - 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 ProfileRecord( - @Nullable AppPredictor appPredictor, - @Nullable ShortcutLoader shortcutLoader) { - this.appPredictor = appPredictor; - this.shortcutLoader = shortcutLoader; - } - - public void destroy() { - if (appPredictor != null) { - appPredictor.destroy(); - } - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt deleted file mode 100644 index 84b7d9a9..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.android.intentresolver.v2 - -import androidx.activity.ComponentActivity -import androidx.annotation.OpenForTesting - -/** - * Activity logic for [ChooserActivity]. - * - * TODO: Make this class no longer open once [ChooserActivity] no longer needs to cast to access - * [chooserRequest]. For now, this class being open is better than using reflection there. - */ -@OpenForTesting -open class ChooserActivityLogic( - tag: String, - activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, -) : - ActivityLogic, - CommonActivityLogic by CommonActivityLogicImpl( - tag, - activity, - onWorkProfileStatusUpdated, - ) diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt deleted file mode 100644 index 17bc2731..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2 - -import android.app.Activity -import androidx.activity.ComponentActivity -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import dagger.hilt.android.scopes.ActivityScoped -import javax.inject.Inject - -/** - * __Purpose__ - * - * Cleanup aid. Provides a pathway to cleaner code. - * - * __Incoming References__ - * - * For use by ChooserActivity only; must not be accessed by any code outside of ChooserActivity. - * This prevents circular dependencies and coupling, and maintains unidirectional flow. This is - * important for maintaining a migration path towards healthier architecture. - * - * __Outgoing References__ - * - * _ChooserActivity_ - * - * This class must only reference it's host as Activity/ComponentActivity; no down-cast to - * [ChooserActivity]. Other components should be passed in and not pulled from other places. This - * prevents circular dependencies from forming. - * - * _Elsewhere_ - * - * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of - * referenced from an existing location. If not available for injection, the value should be - * constructed here, then provided to where it is needed. If existing objects from ChooserActivity - * are required, supply a factory interface which satisfies the necessary dependencies and use it - * during construction. - */ - -@ActivityScoped -class ChooserHelper @Inject constructor( - hostActivity: Activity, -) : DefaultLifecycleObserver { - // This is guaranteed by Hilt, since only a ComponentActivity is injectable. - private val activity: ComponentActivity = hostActivity as ComponentActivity - - private var activityPostCreate: Runnable? = null - - init { - activity.lifecycle.addObserver(this) - } - - /** - * Provides a optional callback to setup state which is not yet possible to do without circular - * dependencies or by moving more code. - */ - fun setPostCreateCallback(onPostCreate: Runnable) { - activityPostCreate = onPostCreate - } - - /** - * Invoked by Lifecycle, after Activity.onCreate() _returns_. - */ - override fun onCreate(owner: LifecycleOwner) { - activityPostCreate?.run() - } -}
\ No newline at end of file diff --git a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt deleted file mode 100644 index 2f8ccf77..00000000 --- a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2 - -import android.service.chooser.ChooserAction -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi -import com.android.intentresolver.contentpreview.MutableActionFactory -import com.android.intentresolver.widget.ActionRow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow - -/** A wrapper around [ChooserActionFactory] that provides observable custom actions */ -class ChooserMutableActionFactory( - private val actionFactory: ChooserActionFactory, -) : MutableActionFactory, ChooserContentPreviewUi.ActionFactory by actionFactory { - private val customActions = - MutableStateFlow<List<ActionRow.Action>>(actionFactory.createCustomActions()) - - override val customActionsFlow: Flow<List<ActionRow.Action>> - get() = customActions - - override fun updateCustomActions(actions: List<ChooserAction>) { - customActions.tryEmit(mapChooserActions(actions)) - } - - override fun createCustomActions(): List<ActionRow.Action> = customActions.value - - private fun mapChooserActions(chooserActions: List<ChooserAction>): List<ActionRow.Action> = - buildList(chooserActions.size) { - chooserActions.forEachIndexed { i, chooserAction -> - val actionRow = - actionFactory.createCustomAction(chooserAction) { - actionFactory.logCustomAction(i) - } - if (actionRow != null) { - add(actionRow) - } - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt deleted file mode 100644 index 784096b4..00000000 --- a/java/src/com/android/intentresolver/v2/ProfileHelper.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* -* Copyright (C) 2024 The Android Open Source Project -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -*/ - -package com.android.intentresolver.v2 - -import android.os.UserHandle -import com.android.intentresolver.inject.IntentResolverFlags -import com.android.intentresolver.v2.domain.interactor.UserInteractor -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.shared.model.User -import javax.inject.Inject - -class ProfileHelper @Inject constructor( - interactor: UserInteractor, - private val flags: IntentResolverFlags, - profiles: List<Profile>, - launchedAsProfile: Profile, -) { - private val launchedByHandle: UserHandle = interactor.launchedAs - - // Map UserHandle back to a user within launchedByProfile - private val launchedByUser = when (launchedByHandle) { - launchedAsProfile.primary.handle -> launchedAsProfile.primary - launchedAsProfile.clone?.handle -> launchedAsProfile.clone - else -> error("launchedByUser must be a member of launchedByProfile") - } - val launchedAsProfileType: Profile.Type = launchedAsProfile.type - - val personalProfile = profiles.single { it.type == Profile.Type.PERSONAL } - val workProfile = profiles.singleOrNull { it.type == Profile.Type.WORK } - val privateProfile = profiles.singleOrNull { it.type == Profile.Type.PRIVATE } - - val personalHandle = personalProfile.primary.handle - val workHandle = workProfile?.primary?.handle - val privateHandle = privateProfile?.primary?.handle?.takeIf { flags.enablePrivateProfile() } - val cloneHandle = personalProfile.clone?.handle - - val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone - - val cloneUserPresent = personalProfile.clone != null - val workProfilePresent = workProfile != null - val privateProfilePresent = privateProfile != null - - // Name retained for ease of review, to be renamed later - val tabOwnerUserHandleForLaunch = if (launchedByUser.role == User.Role.CLONE) { - // When started by clone user, return the profile owner instead - launchedAsProfile.primary.handle - } else { - // Otherwise the launched user is used - launchedByUser.handle - } - - // Name retained for ease of review, to be renamed later - fun getQueryIntentsHandle(handle: UserHandle): UserHandle? { - return if (isLaunchedAsCloneProfile && handle == personalHandle) { - cloneHandle - } else { - handle - } - } -} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java deleted file mode 100644 index a9d9f8b1..00000000 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ /dev/null @@ -1,1980 +0,0 @@ -/* - * Copyright (C) 2008 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -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.content.Intent.FLAG_ACTIVITY_NEW_TASK; -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 static com.android.intentresolver.v2.ext.CreationExtrasExtKt.addDefaultArgs; -import static com.android.intentresolver.v2.ui.viewmodel.ResolverRequestReaderKt.readResolverRequest; -import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; - -import static java.util.Objects.requireNonNull; - -import android.app.ActivityThread; -import android.app.VoiceInteractor.PickOptionRequest; -import android.app.VoiceInteractor.PickOptionRequest.Option; -import android.app.VoiceInteractor.Prompt; -import android.app.admin.DevicePolicyEventLogger; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; -import android.content.pm.ResolveInfo; -import android.content.pm.UserInfo; -import android.content.res.Configuration; -import android.graphics.Insets; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.PatternMatcher; -import android.os.RemoteException; -import android.os.StrictMode; -import android.os.Trace; -import android.os.UserHandle; -import android.os.UserManager; -import android.provider.Settings; -import android.stats.devicepolicy.DevicePolicyEnums; -import android.text.TextUtils; -import android.util.Log; -import android.util.Slog; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewGroup.LayoutParams; -import android.view.Window; -import android.view.WindowInsets; -import android.view.WindowManager; -import android.widget.AbsListView; -import android.widget.AdapterView; -import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.ListView; -import android.widget.Space; -import android.widget.TabHost; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.viewmodel.CreationExtras; -import androidx.viewpager.widget.ViewPager; - -import com.android.intentresolver.AnnotatedUserHandles; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.WorkProfileAvailabilityManager; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.intentresolver.icons.DefaultTargetDataLoader; -import com.android.intentresolver.icons.TargetDataLoader; -import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; -import com.android.intentresolver.v2.data.repository.DevicePolicyResources; -import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvider; -import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; -import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; -import com.android.intentresolver.v2.emptystate.ResolverWorkProfilePausedEmptyStateProvider; -import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter; -import com.android.intentresolver.v2.profiles.MultiProfilePagerAdapter.ProfileType; -import com.android.intentresolver.v2.profiles.OnProfileSelectedListener; -import com.android.intentresolver.v2.profiles.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.v2.profiles.ResolverMultiProfilePagerAdapter; -import com.android.intentresolver.v2.profiles.TabConfig; -import com.android.intentresolver.v2.shared.model.Profile; -import com.android.intentresolver.v2.ui.ActionTitle; -import com.android.intentresolver.v2.ui.model.ActivityModel; -import com.android.intentresolver.v2.ui.model.ResolverRequest; -import com.android.intentresolver.v2.validation.Finding; -import com.android.intentresolver.v2.validation.FindingsKt; -import com.android.intentresolver.v2.validation.Invalid; -import com.android.intentresolver.v2.validation.Valid; -import com.android.intentresolver.v2.validation.ValidationResult; -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.google.common.collect.ImmutableList; - -import dagger.hilt.android.AndroidEntryPoint; - -import kotlin.Pair; -import kotlin.Unit; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.function.Consumer; - -import javax.inject.Inject; - -/** - * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is - * *not* the resolver that is actually triggered by the system right now (you want - * frameworks/base/core/java/com/android/internal/app/ResolverActivity.java for that), the full - * migration is not complete. - */ -@AndroidEntryPoint(FragmentActivity.class) -public class ResolverActivity extends Hilt_ResolverActivity implements - ResolverListAdapter.ResolverListCommunicator { - - @Inject public PackageManager mPackageManager; - @Inject public DevicePolicyResources mDevicePolicyResources; - @Inject public IntentForwarding mIntentForwarding; - private ResolverRequest mResolverRequest; - private ActivityModel mActivityModel; - protected ActivityLogic mLogic; - protected TargetDataLoader mTargetDataLoader; - private boolean mResolvingHome; - - private Button mAlwaysButton; - private Button mOnceButton; - protected View mProfileView; - private int mLastSelected = AbsListView.INVALID_POSITION; - private int mLayoutId; - private PickTargetOptionRequest mPickOptionRequest; - // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. - protected ResolverDrawerLayout mResolverDrawerLayout; - - private static final String TAG = "ResolverActivity"; - private static final boolean DEBUG = false; - private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; - - private boolean mRegistered; - - protected Insets mSystemWindowInsets = null; - private Space mFooterSpacer = null; - - 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 final boolean mWorkProfileHasBeenEnabled = false; - - protected static final String TAB_TAG_PERSONAL = "personal"; - protected static final String TAB_TAG_WORK = "work"; - - private PackageMonitor mPersonalPackageMonitor; - private PackageMonitor mWorkPackageMonitor; - - protected ResolverMultiProfilePagerAdapter mMultiProfilePagerAdapter; - - public static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; - public static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; - - private UserHandle mHeaderCreatorUser; - - @Nullable - private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { - return new PackageMonitor() { - @Override - public void onSomePackagesChanged() { - listAdapter.handlePackagesChanged(); - } - - @Override - public boolean onPackageChanged(String packageName, int uid, String[] components) { - // We care about all package changes, not just the whole package itself which is - // default behavior. - return true; - } - }; - } - protected ActivityModel createActivityModel() { - return ActivityModel.createFrom(this); - } - - @VisibleForTesting - protected ActivityLogic createActivityLogic() { - return new ResolverActivityLogic( - TAG, - /* activity = */ this, - this::onWorkProfileStatusUpdated); - } - - @NonNull - @Override - public CreationExtras getDefaultViewModelCreationExtras() { - return addDefaultArgs( - super.getDefaultViewModelCreationExtras(), - new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, ActivityModel.createFrom(this))); - } - - @Override - protected final void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setTheme(R.style.Theme_DeviceDefault_Resolver); - mActivityModel = createActivityModel(); - - Log.i(TAG, "onCreate"); - Log.i(TAG, "activityModel=" + mActivityModel.toString()); - int callerUid = mActivityModel.getLaunchedFromUid(); - if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { - Log.e(TAG, "Can't start a resolver from uid " + callerUid); - finish(); - } - - ValidationResult<ResolverRequest> result = readResolverRequest(mActivityModel); - if (result instanceof Invalid) { - ((Invalid) result).getErrors().forEach(new Consumer<Finding>() { - @Override - public void accept(Finding finding) { - FindingsKt.log(finding, TAG); - } - }); - finish(); - } - mResolverRequest = ((Valid<ResolverRequest>) result).getValue(); - mLogic = createActivityLogic(); - mResolvingHome = mResolverRequest.isResolvingHome(); - mTargetDataLoader = new DefaultTargetDataLoader( - this, - getLifecycle(), - mResolverRequest.isAudioCaptureDevice()); - init(); - restore(savedInstanceState); - } - - private void init() { - Intent intent = mResolverRequest.getIntent(); - - // 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 - // turn this off when running under voice interaction, since it results in - // a more complicated UI that the current voice interaction flow is not able - // to handle. We also turn it off when multiple tabs are shown to simplify the UX. - // We also turn it off when clonedProfile is present on the device, because we might have - // different "last chosen" activities in the different profiles, and PackageManager doesn't - // provide any more information to help us select between them. - boolean filterLastUsed = !isVoiceInteraction() - && !hasWorkProfile() && !hasCloneProfile(); - mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - new Intent[0], - /* resolutionList = */ mResolverRequest.getResolutionList(), - filterLastUsed - ); - if (configureContentView(mTargetDataLoader)) { - return; - } - - mPersonalPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getPersonalListAdapter()); - mPersonalPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, - false - ); - if (hasWorkProfile()) { - mWorkPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getWorkListAdapter()); - mWorkPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, - false - ); - } - - mRegistered = true; - - final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); - if (rdl != null) { - rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { - @Override - public void onDismissed() { - finish(); - } - }); - - boolean hasTouchScreen = mPackageManager - .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); - - if (isVoiceInteraction() || !hasTouchScreen) { - rdl.setCollapsed(false); - } - - rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); - - mResolverDrawerLayout = rdl; - } - - final Set<String> categories = intent.getCategories(); - MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() - ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED - : MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, - intent.getAction() + ":" + intent.getType() + ":" - + (categories != null ? Arrays.toString(categories.toArray()) : "")); - } - - private void restore(@Nullable Bundle savedInstanceState) { - if (savedInstanceState != null) { - // onRestoreInstanceState - resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); - } - } - - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - - protected ResolverMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, - List<ResolveInfo> resolutionList, - boolean filterLastUsed) { - ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; - if (hasWorkProfile()) { - resolverMultiProfilePagerAdapter = - createResolverMultiProfilePagerAdapterForTwoProfiles( - initialIntents, resolutionList, filterLastUsed); - } else { - resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( - initialIntents, resolutionList, filterLastUsed); - } - return resolverMultiProfilePagerAdapter; - } - - protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); - - if (!shouldShowNoCrossProfileIntentsEmptyState) { - // Implementation that doesn't show any blockers - return new EmptyStateProvider() {}; - } - - final 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 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( - requireAnnotatedUserHandles().personalProfileUserHandle, - noWorkToPersonalEmptyState, - noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - - /** - * Numerous layouts are supported, each with optional ViewGroups. - * Make sure the inset gets added to the correct View, using - * a footer for Lists so it can properly scroll under the navbar. - */ - protected boolean shouldAddFooterView() { - if (useLayoutWithDefault()) return true; - - View buttonBar = findViewById(com.android.internal.R.id.button_bar); - return buttonBar == null || buttonBar.getVisibility() == View.GONE; - } - - protected void applyFooterView(int height) { - if (mFooterSpacer == null) { - mFooterSpacer = new Space(getApplicationContext()); - } else { - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().removeFooterView(mFooterSpacer); - } - mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, - mSystemWindowInsets.bottom)); - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().addFooterView(mFooterSpacer); - } - - protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - mSystemWindowInsets = insets.getSystemWindowInsets(); - - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - - resetButtonBar(); - - if (shouldUseMiniResolver()) { - View buttonContainer = findViewById(com.android.internal.R.id.button_bar_container); - buttonContainer.setPadding(0, 0, 0, mSystemWindowInsets.bottom - + getResources().getDimensionPixelOffset(R.dimen.resolver_button_bar_spacing)); - } - - // Need extra padding so the list can fully scroll up - if (shouldAddFooterView()) { - applyFooterView(mSystemWindowInsets.bottom); - } - - return insets.consumeSystemWindowInsets(); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (hasWorkProfile() && !useLayoutWithDefault() - && !shouldUseMiniResolver()) { - updateIntentPickerPaddings(); - } - - if (mSystemWindowInsets != null) { - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); - } - } - - public int getLayoutResource() { - return R.layout.resolver_list; - } - - @Override - protected void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mResolvingHome) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - // TODO: should we clean up the work-profile manager before we potentially finish() above? - mLogic.getWorkProfileAvailabilityManager().unregisterWorkProfileStateReceiver(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (!isChangingConfigurations() && mPickOptionRequest != null) { - mPickOptionRequest.cancel(); - } - if (mMultiProfilePagerAdapter != null - && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { - mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); - } - } - - // referenced by layout XML: android:onClick="onButtonClick" - public void onButtonClick(View v) { - final int id = v.getId(); - ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); - ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); - int which = currentListAdapter.hasFilteredItem() - ? currentListAdapter.getFilteredPosition() - : listView.getCheckedItemPosition(); - boolean hasIndexBeenFiltered = !currentListAdapter.hasFilteredItem(); - startSelected(which, id == com.android.internal.R.id.button_always, hasIndexBeenFiltered); - } - - public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) { - if (isFinishing()) { - return; - } - ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() - .resolveInfoForPosition(which, hasIndexBeenFiltered); - if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { - String launcherName = ri.activityInfo.loadLabel(mPackageManager).toString(); - Toast.makeText(this, - mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), - Toast.LENGTH_LONG).show(); - return; - } - - TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(which, hasIndexBeenFiltered); - if (target == null) { - return; - } - if (onTargetSelected(target, always)) { - if (always) { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); - } else { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); - } - MetricsLogger.action(this, - mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() - ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED - : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); - finish(); - } - } - - @Override // ResolverListCommunicator - public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - return defIntent; - } - - protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { - final ItemClickListener listener = new ItemClickListener(); - setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); - if (hasWorkProfile()) { - final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); - if (rdl != null) { - rdl.setMaxCollapsedHeight(getResources() - .getDimensionPixelSize(useLayoutWithDefault() - ? R.dimen.resolver_max_collapsed_height_with_default_with_tabs - : R.dimen.resolver_max_collapsed_height_with_tabs)); - } - } - } - - protected boolean onTargetSelected(TargetInfo target, boolean always) { - final ResolveInfo ri = target.getResolveInfo(); - final Intent intent = target != null ? target.getResolvedIntent() : null; - - if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/ - && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { - // Build a reasonable intent filter, based on what matched. - IntentFilter filter = new IntentFilter(); - Intent filterIntent; - - if (intent.getSelector() != null) { - filterIntent = intent.getSelector(); - } else { - filterIntent = intent; - } - - String action = filterIntent.getAction(); - if (action != null) { - filter.addAction(action); - } - Set<String> categories = filterIntent.getCategories(); - if (categories != null) { - for (String cat : categories) { - filter.addCategory(cat); - } - } - filter.addCategory(Intent.CATEGORY_DEFAULT); - - int cat = ri.match & IntentFilter.MATCH_CATEGORY_MASK; - Uri data = filterIntent.getData(); - if (cat == IntentFilter.MATCH_CATEGORY_TYPE) { - String mimeType = filterIntent.resolveType(this); - if (mimeType != null) { - try { - filter.addDataType(mimeType); - } catch (IntentFilter.MalformedMimeTypeException e) { - Log.w("ResolverActivity", e); - filter = null; - } - } - } - if (data != null && data.getScheme() != null) { - // We need the data specification if there was no type, - // OR if the scheme is not one of our magical "file:" - // or "content:" schemes (see IntentFilter for the reason). - if (cat != IntentFilter.MATCH_CATEGORY_TYPE - || (!"file".equals(data.getScheme()) - && !"content".equals(data.getScheme()))) { - filter.addDataScheme(data.getScheme()); - - // Look through the resolved filter to determine which part - // of it matched the original Intent. - Iterator<PatternMatcher> pIt = ri.filter.schemeSpecificPartsIterator(); - if (pIt != null) { - String ssp = data.getSchemeSpecificPart(); - while (ssp != null && pIt.hasNext()) { - PatternMatcher p = pIt.next(); - if (p.match(ssp)) { - filter.addDataSchemeSpecificPart(p.getPath(), p.getType()); - break; - } - } - } - Iterator<IntentFilter.AuthorityEntry> aIt = ri.filter.authoritiesIterator(); - if (aIt != null) { - while (aIt.hasNext()) { - IntentFilter.AuthorityEntry a = aIt.next(); - if (a.match(data) >= 0) { - int port = a.getPort(); - filter.addDataAuthority(a.getHost(), - port >= 0 ? Integer.toString(port) : null); - break; - } - } - } - pIt = ri.filter.pathsIterator(); - if (pIt != null) { - String path = data.getPath(); - while (path != null && pIt.hasNext()) { - PatternMatcher p = pIt.next(); - if (p.match(path)) { - filter.addDataPath(p.getPath(), p.getType()); - break; - } - } - } - } - } - - if (filter != null) { - final int N = mMultiProfilePagerAdapter.getActiveListAdapter() - .getUnfilteredResolveList().size(); - ComponentName[] set; - // If we don't add back in the component for forwarding the intent to a managed - // profile, the preferred activity may not be updated correctly (as the set of - // components we tell it we knew about will have changed). - final boolean needToAddBackProfileForwardingComponent = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null; - if (!needToAddBackProfileForwardingComponent) { - set = new ComponentName[N]; - } else { - set = new ComponentName[N + 1]; - } - - int bestMatch = 0; - for (int i = 0; i < N; i++) { - ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter() - .getUnfilteredResolveList().get(i).getResolveInfoAt(0); - set[i] = new ComponentName(r.activityInfo.packageName, - r.activityInfo.name); - if (r.match > bestMatch) bestMatch = r.match; - } - - if (needToAddBackProfileForwardingComponent) { - set[N] = mMultiProfilePagerAdapter.getActiveListAdapter() - .getOtherProfile().getResolvedComponentName(); - final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter() - .getOtherProfile().getResolveInfo().match; - if (otherProfileMatch > bestMatch) bestMatch = otherProfileMatch; - } - - if (always) { - final int userId = getUserId(); - final PackageManager pm = mPackageManager; - - // Set the preferred Activity - pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); - - if (ri.handleAllWebDataURI) { - // Set default Browser if needed - final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId); - if (TextUtils.isEmpty(packageName)) { - pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, - userId); - } - } - } else { - try { - mMultiProfilePagerAdapter.getActiveListAdapter() - .mResolverListController.setLastChosen(intent, filter, bestMatch); - } catch (RemoteException re) { - Log.d(TAG, "Error calling setLastChosenActivity\n" + re); - } - } - } - } - - safelyStartActivity(target); - - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - return !target.isSuspended(); - } - - @Override // ResolverListCommunicator - public boolean shouldGetActivityMetadata() { - return false; - } - - public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - return !target.isSuspended(); - } - - @VisibleForTesting - protected ResolverListController createListController(UserHandle userHandle) { - ResolverRankerServiceResolverComparator resolverComparator = - new ResolverRankerServiceResolverComparator( - this, - mResolverRequest.getIntent(), - mActivityModel.getReferrerPackage(), - null, - null, - getResolverRankerServiceUserHandleList(userHandle), - null); - return new ResolverListController( - this, - mPackageManager, - mActivityModel.getIntent(), - mActivityModel.getReferrerPackage(), - mActivityModel.getLaunchedFromUid(), - resolverComparator, - getQueryIntentsUser(userHandle)); - } - - /** - * Finishing procedures to be performed after the list has been rebuilt. - * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList. - * @param rebuildCompleted - * @return <code>true</code> if the activity is finishing and creation should halt. - */ - protected boolean postRebuildList(boolean rebuildCompleted) { - return postRebuildListInternal(rebuildCompleted); - } - - /** - * Callback called when user changes the profile tab. - */ - /* TODO: consider merging with the customized considerations of our implemented - * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions - * between the respective listener callbacks would occur in the triggering patterns during init - * (when the `OnProfileSelectedListener` is registered after a possible tab-change), or possibly - * if there's some way to trigger an update in one model but not the other. If there's an - * initialization dependency, we can probably reason about it with confidence. If there's a - * discrepancy between the `TabHost` and pager-adapter data models, that inconsistency is - * likely to be a bug that would benefit from consolidation. - */ - protected void onProfileTabSelected(int currentPage) { - setupViewVisibilities(); - maybeLogProfileChange(); - if (hasWorkProfile()) { - // The device policy logger is only concerned with sessions that include a work profile. - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(currentPage) - .setStrings(getMetricsCategory()) - .write(); - } - } - - /** - * Add a label to signify that the user can pick a different app. - * @param adapter The adapter used to provide data to item views. - */ - public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { - final boolean useHeader = adapter.hasFilteredItem(); - if (useHeader) { - FrameLayout stub = findViewById(com.android.internal.R.id.stub); - stub.setVisibility(View.VISIBLE); - TextView textView = (TextView) LayoutInflater.from(this).inflate( - R.layout.resolver_different_item_header, null, false); - if (hasWorkProfile()) { - textView.setGravity(Gravity.CENTER); - } - stub.addView(textView); - } - } - - protected void resetButtonBar() { - final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); - if (buttonLayout == null) { - Log.e(TAG, "Layout unexpectedly does not have a button bar"); - return; - } - ResolverListAdapter activeListAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); - View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider); - if (!useLayoutWithDefault()) { - int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; - buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(), - buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize( - R.dimen.resolver_button_bar_spacing) + inset); - } - if (activeListAdapter.isTabLoaded() - && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter) - && !useLayoutWithDefault()) { - buttonLayout.setVisibility(View.INVISIBLE); - if (buttonBarDivider != null) { - buttonBarDivider.setVisibility(View.INVISIBLE); - } - setButtonBarIgnoreOffset(/* ignoreOffset */ false); - return; - } - if (buttonBarDivider != null) { - buttonBarDivider.setVisibility(View.VISIBLE); - } - buttonLayout.setVisibility(View.VISIBLE); - setButtonBarIgnoreOffset(/* ignoreOffset */ true); - - mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once); - mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always); - - resetAlwaysOrOnceButtonBar(); - } - - protected String getMetricsCategory() { - return METRICS_CATEGORY_RESOLVER; - } - - @Override // ResolverListCommunicator - public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (!mMultiProfilePagerAdapter.onHandlePackagesChanged( - listAdapter, - mLogic.getWorkProfileAvailabilityManager().isWaitingToEnableWorkProfile())) { - // We no longer have any items... just finish the activity. - finish(); - } - } - - protected void maybeLogProfileChange() {} - - @VisibleForTesting - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - return new CrossProfileIntentsChecker(getContentResolver()); - } - - protected Unit onWorkProfileStatusUpdated() { - if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) { - mMultiProfilePagerAdapter.rebuildActiveTab(true); - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - return Unit.INSTANCE; - } - - // @NonFinalForTesting - @VisibleForTesting - protected ResolverListAdapter createResolverListAdapter( - Context context, - List<Intent> payloadIntents, - Intent[] initialIntents, - List<ResolveInfo> resolutionList, - boolean filterLastUsed, - UserHandle userHandle) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) - ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; - return new ResolverListAdapter( - context, - payloadIntents, - initialIntents, - resolutionList, - filterLastUsed, - createListController(userHandle), - userHandle, - mResolverRequest.getIntent(), - this, - initialIntentsUserSpace, - mTargetDataLoader); - } - - protected final EmptyStateProvider createEmptyStateProvider( - @Nullable UserHandle workProfileUserHandle) { - final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); - - final EmptyStateProvider workProfileOffEmptyStateProvider = - new ResolverWorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mLogic.getWorkProfileAvailabilityManager(), - /* onSwitchOnWorkSelectedListener= */ - () -> { - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - }, - getMetricsCategory()); - - final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( - this, - workProfileUserHandle, - requireAnnotatedUserHandles().personalProfileUserHandle, - getMetricsCategory(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch - ); - - // Return composite provider, the order matters (the higher, the more priority) - return new CompositeEmptyStateProvider( - blockerEmptyStateProvider, - workProfileOffEmptyStateProvider, - noAppsEmptyStateProvider - ); - } - - private ResolverMultiProfilePagerAdapter - createResolverMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List<ResolveInfo> resolutionList, - boolean filterLastUsed) { - ResolverListAdapter personalAdapter = createResolverListAdapter( - /* context */ this, - mResolverRequest.getPayloadIntents(), - initialIntents, - resolutionList, - filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle - ); - return new ResolverMultiProfilePagerAdapter( - /* context */ this, - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - personalAdapter)), - createEmptyStateProvider(/* workProfileUserHandle= */ null), - /* workProfileQuietModeChecker= */ () -> false, - /* defaultProfile= */ PROFILE_PERSONAL, - /* workProfileUserHandle= */ null, - requireAnnotatedUserHandles().cloneProfileUserHandle); - } - - private UserHandle getIntentUser() { - return Objects.requireNonNullElse(mResolverRequest.getCallingUser(), - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - - private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( - Intent[] initialIntents, - List<ResolveInfo> resolutionList, - boolean filterLastUsed) { - // In the edge case when we have 0 apps in the current profile and >1 apps in the other, - // 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 = getIntentUser(); - if (!requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch.equals(intentUser)) { - if (requireAnnotatedUserHandles().personalProfileUserHandle.equals(intentUser)) { - selectedProfile = PROFILE_PERSONAL; - } else if (requireAnnotatedUserHandles().workProfileUserHandle.equals(intentUser)) { - selectedProfile = PROFILE_WORK; - } - } else { - int selectedProfileExtra = getSelectedProfileExtra(); - if (selectedProfileExtra != -1) { - selectedProfile = selectedProfileExtra; - } - } - // We only show the default app for the profile of the current user. The filterLastUsed - // flag determines whether to show a default app and that app is not shown in the - // resolver list. So filterLastUsed should be false for the other profile. - ResolverListAdapter personalAdapter = createResolverListAdapter( - /* context */ this, - mResolverRequest.getPayloadIntents(), - selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - resolutionList, - (filterLastUsed && UserHandle.myUserId() - == requireAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle - ); - UserHandle workProfileUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; - ResolverListAdapter workAdapter = createResolverListAdapter( - /* context */ this, - mResolverRequest.getPayloadIntents(), - selectedProfile == PROFILE_WORK ? initialIntents : null, - resolutionList, - (filterLastUsed && UserHandle.myUserId() - == workProfileUserHandle.getIdentifier()), - /* userHandle */ workProfileUserHandle - ); - return new ResolverMultiProfilePagerAdapter( - /* context */ this, - ImmutableList.of( - new TabConfig<>( - PROFILE_PERSONAL, - mDevicePolicyResources.getPersonalTabLabel(), - mDevicePolicyResources.getPersonalTabAccessibilityLabel(), - TAB_TAG_PERSONAL, - personalAdapter), - new TabConfig<>( - PROFILE_WORK, - mDevicePolicyResources.getWorkTabLabel(), - mDevicePolicyResources.getWorkTabAccessibilityLabel(), - TAB_TAG_WORK, - workAdapter)), - createEmptyStateProvider(workProfileUserHandle), - () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), - selectedProfile, - workProfileUserHandle, - requireAnnotatedUserHandles().cloneProfileUserHandle); - } - - /** - * Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link - * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. - */ - final int getSelectedProfileExtra() { - Profile.Type selected = mResolverRequest.getSelectedProfile(); - if (selected == null) { - return -1; - } - switch (selected) { - case PERSONAL: return PROFILE_PERSONAL; - case WORK: return PROFILE_WORK; - default: return -1; - } - } - - protected final @ProfileType int getCurrentProfile() { - UserHandle launchUser = requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch; - UserHandle personalUser = requireAnnotatedUserHandles().personalProfileUserHandle; - return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; - } - - private AnnotatedUserHandles requireAnnotatedUserHandles() { - return requireNonNull(mLogic.getAnnotatedUserHandles()); - } - - private boolean hasWorkProfile() { - return requireAnnotatedUserHandles().workProfileUserHandle != null; - } - - private boolean hasCloneProfile() { - return requireAnnotatedUserHandles().cloneProfileUserHandle != null; - } - - protected final boolean isLaunchedAsCloneProfile() { - UserHandle launchUser = requireAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - UserHandle cloneUser = requireAnnotatedUserHandles().cloneProfileUserHandle; - return hasCloneProfile() && launchUser.equals(cloneUser); - } - - private void updateIntentPickerPaddings() { - View titleCont = findViewById(com.android.internal.R.id.title_container); - titleCont.setPadding( - titleCont.getPaddingLeft(), - titleCont.getPaddingTop(), - titleCont.getPaddingRight(), - getResources().getDimensionPixelSize(R.dimen.resolver_title_padding_bottom)); - View buttonBar = findViewById(com.android.internal.R.id.button_bar); - buttonBar.setPadding( - buttonBar.getPaddingLeft(), - getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing), - buttonBar.getPaddingRight(), - getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing)); - } - - private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { - return; - } - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean( - currentUserHandle.equals( - requireAnnotatedUserHandles().personalProfileUserHandle)) - .setStrings(getMetricsCategory(), - cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") - .write(); - } - - @Override // ResolverListCommunicator - public final void sendVoiceChoicesIfNeeded() { - if (!isVoiceInteraction()) { - // Clearly not needed. - return; - } - - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount(); - final Option[] options = new Option[count]; - for (int i = 0; i < options.length; i++) { - TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); - if (target == null) { - // If this occurs, a new set of targets is being loaded. Let that complete, - // and have the next call to send voice choices proceed instead. - return; - } - options[i] = optionForChooserTarget(target, i); - } - - mPickOptionRequest = new PickTargetOptionRequest( - new Prompt(getTitle()), options, null); - getVoiceInteractor().submitRequest(mPickOptionRequest); - } - - final Option optionForChooserTarget(TargetInfo target, int index) { - return new Option(getOrLoadDisplayLabel(target), index); - } - - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { - final ActionTitle title = mResolvingHome - ? ActionTitle.HOME - : ActionTitle.forAction(intent.getAction()); - - // While there may already be a filtered item, we can only use it in the title if the list - // is already sorted and all information relevant to it is already in the list. - final boolean named = - mMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; - if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { - return getString(defaultTitleRes); - } else { - return named - ? getString( - title.namedTitleRes, - getOrLoadDisplayLabel( - mMultiProfilePagerAdapter - .getActiveListAdapter().getFilteredItem())) - : getString(title.titleRes); - } - } - - @Override - protected final void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().personalProfileUserHandle, - false); - if (hasWorkProfile()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register( - this, - getMainLooper(), - requireAnnotatedUserHandles().workProfileUserHandle, - false); - } - mRegistered = true; - } - WorkProfileAvailabilityManager workProfileAvailabilityManager = - mLogic.getWorkProfileAvailabilityManager(); - if (hasWorkProfile() && workProfileAvailabilityManager.isWaitingToEnableWorkProfile()) { - if (workProfileAvailabilityManager.isQuietModeEnabled()) { - workProfileAvailabilityManager.markWorkProfileEnabledBroadcastReceived(); - } - } - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - } - - @Override - protected final void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - - @Override - protected final void onStart() { - super.onStart(); - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (hasWorkProfile()) { - mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); - } - } - - private boolean hasManagedProfile() { - UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - if (userManager == null) { - return false; - } - - try { - List<UserInfo> profiles = userManager.getProfiles(getUserId()); - for (UserInfo userInfo : profiles) { - if (userInfo != null && userInfo.isManagedProfile()) { - return true; - } - } - } catch (SecurityException e) { - return false; - } - return false; - } - - private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { - try { - ApplicationInfo appInfo = mPackageManager.getApplicationInfo( - resolveInfo.activityInfo.packageName, 0 /* default flags */); - return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; - } catch (NameNotFoundException e) { - return false; - } - } - - private void setAlwaysButtonEnabled(boolean hasValidSelection, int checkedPos, - boolean filtered) { - if (!mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getUser())) { - // Never allow the inactive profile to always open an app. - mAlwaysButton.setEnabled(false); - return; - } - // In case of clonedProfile being active, we do not allow the 'Always' option in the - // disambiguation dialog of Personal Profile as the package manager cannot distinguish - // between cross-profile preferred activities. - if (hasCloneProfile() - && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) { - mAlwaysButton.setEnabled(false); - return; - } - boolean enabled = false; - ResolveInfo ri = null; - if (hasValidSelection) { - ri = mMultiProfilePagerAdapter.getActiveListAdapter() - .resolveInfoForPosition(checkedPos, filtered); - if (ri == null) { - Log.e(TAG, "Invalid position supplied to setAlwaysButtonEnabled"); - return; - } else if (ri.targetUserId != UserHandle.USER_CURRENT) { - Log.e(TAG, "Attempted to set selection to resolve info for another user"); - return; - } else { - enabled = true; - } - - mAlwaysButton.setText(getResources() - .getString(R.string.activity_resolver_use_always)); - } - - if (ri != null) { - ActivityInfo activityInfo = ri.activityInfo; - - boolean hasRecordPermission = mPackageManager - .checkPermission(android.Manifest.permission.RECORD_AUDIO, - activityInfo.packageName) - == PackageManager.PERMISSION_GRANTED; - - if (!hasRecordPermission) { - // OK, we know the record permission, is this a capture device - boolean hasAudioCapture = mResolverRequest.isAudioCaptureDevice(); - enabled = !hasAudioCapture; - } - } - mAlwaysButton.setEnabled(enabled); - } - - @Override // ResolverListCommunicator - public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, - boolean rebuildCompleted) { - if (isAutolaunching()) { - return; - } - mMultiProfilePagerAdapter.setUseLayoutWithDefault(useLayoutWithDefault()); - - if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { - mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); - } else { - mMultiProfilePagerAdapter.showListView(listAdapter); - } - // showEmptyResolverListEmptyState can mark the tab as loaded, - // which is a precondition for auto launching - if (rebuildCompleted && maybeAutolaunchActivity()) { - return; - } - if (doPostProcessing) { - maybeCreateHeader(listAdapter); - resetButtonBar(); - onListRebuilt(listAdapter, rebuildCompleted); - } - } - - /** Start the activity specified by the {@link TargetInfo}.*/ - public final void safelyStartActivity(TargetInfo cti) { - // In case cloned apps are present, we would want to start those apps in cloned user - // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle - // identifies the correct user space in such cases. - UserHandle activityUserHandle = cti.getResolveInfo().userHandle; - safelyStartActivityAsUser(cti, activityUserHandle, null); - } - - /** - * Start activity as a fixed user handle. - * @param cti TargetInfo to be launched. - * @param user User to launch this activity as. - */ - @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) - public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { - safelyStartActivityAsUser(cti, user, null); - } - - protected final void safelyStartActivityAsUser( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // We're dispatching intents that might be coming from legacy apps, so - // don't kill ourselves. - StrictMode.disableDeathOnFileUriExposure(); - try { - safelyStartActivityInternal(cti, user, options); - } finally { - StrictMode.enableDeathOnFileUriExposure(); - } - } - - final void showTargetDetails(ResolveInfo ri) { - Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); - startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle()); - } - - /** - * Sets up the content view. - * @return <code>true</code> if the activity is finishing and creation should halt. - */ - private boolean configureContentView(TargetDataLoader targetDataLoader) { - if (mMultiProfilePagerAdapter.getActiveListAdapter() == null) { - throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " - + "cannot be null."); - } - Trace.beginSection("configureContentView"); - // We partially rebuild the inactive adapter to determine if we should auto launch - // isTabLoaded will be true here if the empty state screen is shown instead of the list. - // To date, we really only care about "partially rebuilding" tabs for work and/or personal. - boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile()); - - if (shouldUseMiniResolver()) { - configureMiniResolverContent(targetDataLoader); - Trace.endSection(); - return false; - } - - if (useLayoutWithDefault()) { - mLayoutId = R.layout.resolver_list_with_default; - } else { - mLayoutId = getLayoutResource(); - } - setContentView(mLayoutId); - mMultiProfilePagerAdapter.setupViewPager(findViewById(com.android.internal.R.id.profile_pager)); - boolean result = postRebuildList(rebuildCompleted); - Trace.endSection(); - return result; - } - - /** - * Mini resolver is shown when the user is choosing between browser[s] in this profile and a - * single app in the other profile (see shouldUseMiniResolver()). It shows the single app icon - * and asks the user if they'd like to open that cross-profile app or use the in-profile - * browser. - */ - private void configureMiniResolverContent(TargetDataLoader targetDataLoader) { - mLayoutId = R.layout.miniresolver; - setContentView(mLayoutId); - - boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; - - ResolverListAdapter sameProfileAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getPersonalListAdapter() - : mMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter inactiveAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getWorkListAdapter() - : mMultiProfilePagerAdapter.getPersonalListAdapter(); - - DisplayResolveInfo sameProfileResolveInfo = sameProfileAdapter.getFirstDisplayResolveInfo(); - - final DisplayResolveInfo otherProfileResolveInfo = - inactiveAdapter.getFirstDisplayResolveInfo(); - - // Load the icon asynchronously - ImageView icon = findViewById(com.android.internal.R.id.icon); - targetDataLoader.loadAppTargetIcon( - otherProfileResolveInfo, - inactiveAdapter.getUserHandle(), - (drawable) -> { - if (!isDestroyed()) { - otherProfileResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); - new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo); - } - }); - - ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( - getResources().getString( - inWorkProfile - ? R.string.miniresolver_open_in_personal - : R.string.miniresolver_open_in_work, - getOrLoadDisplayLabel(otherProfileResolveInfo))); - ((Button) findViewById(com.android.internal.R.id.use_same_profile_browser)).setText( - inWorkProfile ? R.string.miniresolver_use_work_browser - : R.string.miniresolver_use_personal_browser); - - findViewById(com.android.internal.R.id.use_same_profile_browser).setOnClickListener( - v -> { - safelyStartActivity(sameProfileResolveInfo); - finish(); - }); - - findViewById(com.android.internal.R.id.button_open).setOnClickListener(v -> { - Intent intent = otherProfileResolveInfo.getResolvedIntent(); - safelyStartActivityAsUser(otherProfileResolveInfo, inactiveAdapter.getUserHandle()); - finish(); - }); - } - - private boolean isTwoPagePersonalAndWorkConfiguration() { - return (mMultiProfilePagerAdapter.getCount() == 2) - && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) - && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); - } - - @VisibleForTesting - protected void safelyStartActivityInternal( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // If the target is suspended, the activity will not be successfully launched. - // Do not unregister from package manager updates in this case - if (!cti.isSuspended() && mRegistered) { - if (mPersonalPackageMonitor != null) { - mPersonalPackageMonitor.unregister(); - } - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - // If needed, show that intent is forwarded - // from managed profile to owner or other way around. - String profileSwitchMessage = - mIntentForwarding.forwardMessageFor(mResolverRequest.getIntent()); - if (profileSwitchMessage != null) { - Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); - } - try { - if (cti.startAsCaller(this, options, user.getIdentifier())) { - maybeLogCrossProfileTargetLaunch(cti, user); - } - } catch (RuntimeException e) { - Slog.wtf(TAG, - "Unable to launch as uid " + mActivityModel.getLaunchedFromUid() - + " package " + getLaunchedFromPackage() + ", while running in " - + ActivityThread.currentProcessName(), e); - } - } - - /** - * Finishing procedures to be performed after the list has been rebuilt. - * @param rebuildCompleted - * @return <code>true</code> if the activity is finishing and creation should halt. - */ - final boolean postRebuildListInternal(boolean rebuildCompleted) { - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - - // We only rebuild asynchronously when we have multiple elements to sort. In the case where - // we're already done, we can check if we should auto-launch immediately. - if (rebuildCompleted && maybeAutolaunchActivity()) { - return true; - } - - setupViewVisibilities(); - - if (hasWorkProfile()) { - setupProfileTabs(); - } - - return false; - } - - /** - * Mini resolver should be used when all of the following are true: - * 1. This is the intent picker (ResolverActivity). - * 2. This profile only has web browser matches. - * 3. The other profile has a single non-browser match. - */ - private boolean shouldUseMiniResolver() { - if (!isTwoPagePersonalAndWorkConfiguration()) { - return false; - } - - ResolverListAdapter sameProfileAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getPersonalListAdapter() - : mMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter otherProfileAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getWorkListAdapter() - : mMultiProfilePagerAdapter.getPersonalListAdapter(); - - if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { - Log.d(TAG, "No targets in the current profile"); - return false; - } - - if (otherProfileAdapter.getDisplayResolveInfoCount() != 1) { - Log.d(TAG, "Other-profile count: " + otherProfileAdapter.getDisplayResolveInfoCount()); - return false; - } - - if (otherProfileAdapter.allResolveInfosHandleAllWebDataUri()) { - Log.d(TAG, "Other profile is a web browser"); - return false; - } - - if (!sameProfileAdapter.allResolveInfosHandleAllWebDataUri()) { - Log.d(TAG, "Non-browser found in this profile"); - return false; - } - - return true; - } - - private boolean maybeAutolaunchIfSingleTarget() { - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - if (count != 1) { - return false; - } - - if (mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { - return false; - } - - // Only one target, so we're a candidate to auto-launch! - final TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() - .targetInfoForPosition(0, false); - if (shouldAutoLaunchSingleChoice(target)) { - safelyStartActivity(target); - finish(); - return true; - } - return false; - } - - /** - * When we have just a personal and a work profile, we auto launch in the following scenario: - * - There is 1 resolved target on each profile - * - That target is the same app on both profiles - * - The target app has permission to communicate cross profiles - * - The target app has declared it supports cross-profile communication via manifest metadata - */ - private boolean maybeAutolaunchIfCrossProfileSupported() { - if (!isTwoPagePersonalAndWorkConfiguration()) { - return false; - } - - ResolverListAdapter activeListAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getPersonalListAdapter() - : mMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter inactiveListAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getWorkListAdapter() - : mMultiProfilePagerAdapter.getPersonalListAdapter(); - - if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { - return false; - } - - if ((activeListAdapter.getUnfilteredCount() != 1) - || (inactiveListAdapter.getUnfilteredCount() != 1)) { - return false; - } - - TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); - TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals( - activeProfileTarget.getResolvedComponentName(), - inactiveProfileTarget.getResolvedComponentName())) { - return false; - } - - if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { - return false; - } - - String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { - return false; - } - - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) - .setBoolean(activeListAdapter.getUserHandle() - .equals(requireAnnotatedUserHandles().personalProfileUserHandle)) - .setStrings(getMetricsCategory()) - .write(); - safelyStartActivity(activeProfileTarget); - finish(); - return true; - } - - private boolean isAutolaunching() { - return !mRegistered && isFinishing(); - } - - /** - * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} - */ - private boolean maybeAutolaunchActivity() { - if (!isTwoPagePersonalAndWorkConfiguration()) { - return false; - } - - ResolverListAdapter activeListAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getPersonalListAdapter() - : mMultiProfilePagerAdapter.getWorkListAdapter(); - - ResolverListAdapter inactiveListAdapter = - (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) - ? mMultiProfilePagerAdapter.getWorkListAdapter() - : mMultiProfilePagerAdapter.getPersonalListAdapter(); - - if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { - return false; - } - - if ((activeListAdapter.getUnfilteredCount() != 1) - || (inactiveListAdapter.getUnfilteredCount() != 1)) { - return false; - } - - TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); - TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals( - activeProfileTarget.getResolvedComponentName(), - inactiveProfileTarget.getResolvedComponentName())) { - return false; - } - - if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { - return false; - } - - String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { - return false; - } - - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) - .setBoolean(activeListAdapter.getUserHandle() - .equals(requireAnnotatedUserHandles().personalProfileUserHandle)) - .setStrings(getMetricsCategory()) - .write(); - safelyStartActivity(activeProfileTarget); - finish(); - return true; - } - - private void maybeHideDivider() { - final View divider = findViewById(com.android.internal.R.id.divider); - if (divider == null) { - return; - } - divider.setVisibility(View.GONE); - } - - private void resetCheckedItem() { - mLastSelected = ListView.INVALID_POSITION; - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .clearCheckedItemsInInactiveProfiles(); - } - - private void setupViewVisibilities() { - ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); - if (!mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { - addUseDifferentAppLabelIfNecessary(activeListAdapter); - } - } - - /** - * Updates the button bar container {@code ignoreOffset} layout param. - * <p>Setting this to {@code true} means that the button bar will be glued to the bottom of - * the screen. - */ - private void setButtonBarIgnoreOffset(boolean ignoreOffset) { - View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container); - if (buttonBarContainer != null) { - ResolverDrawerLayout.LayoutParams layoutParams = - (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams(); - layoutParams.ignoreOffset = ignoreOffset; - buttonBarContainer.setLayoutParams(layoutParams); - } - } - - private void setupAdapterListView(ListView listView, ItemClickListener listener) { - listView.setOnItemClickListener(listener); - listView.setOnItemLongClickListener(listener); - listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); - } - - /** - * Configure the area above the app selection list (title, content preview, etc). - */ - private void maybeCreateHeader(ResolverListAdapter listAdapter) { - if (mHeaderCreatorUser != null - && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { - return; - } - if (!hasWorkProfile() - && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setVisibility(View.GONE); - } - } - - CharSequence title = mResolverRequest.getTitle() != null - ? mResolverRequest.getTitle() - : getTitleForAction(mResolverRequest.getIntent(), 0); - - if (!TextUtils.isEmpty(title)) { - final TextView titleView = findViewById(com.android.internal.R.id.title); - if (titleView != null) { - titleView.setText(title); - } - setTitle(title); - } - - final ImageView iconView = findViewById(com.android.internal.R.id.icon); - if (iconView != null) { - listAdapter.loadFilteredItemIconTaskAsync(iconView); - } - mHeaderCreatorUser = listAdapter.getUserHandle(); - } - - private void resetAlwaysOrOnceButtonBar() { - // Disable both buttons initially - setAlwaysButtonEnabled(false, ListView.INVALID_POSITION, false); - mOnceButton.setEnabled(false); - - int filteredPosition = mMultiProfilePagerAdapter.getActiveListAdapter() - .getFilteredPosition(); - if (useLayoutWithDefault() && filteredPosition != ListView.INVALID_POSITION) { - setAlwaysButtonEnabled(true, filteredPosition, false); - mOnceButton.setEnabled(true); - // Focus the button if we already have the default option - mOnceButton.requestFocus(); - return; - } - - // When the items load in, if an item was already selected, enable the buttons - ListView currentAdapterView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); - if (currentAdapterView != null - && currentAdapterView.getCheckedItemPosition() != ListView.INVALID_POSITION) { - setAlwaysButtonEnabled(true, currentAdapterView.getCheckedItemPosition(), true); - mOnceButton.setEnabled(true); - } - } - - @Override // ResolverListCommunicator - public final boolean useLayoutWithDefault() { - // We only use the default app layout when the profile of the active user has a - // filtered item. We always show the same default app even in the inactive user profile. - return mMultiProfilePagerAdapter.getListAdapterForUserHandle( - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch - ).hasFilteredItem(); - } - - final class ItemClickListener implements AdapterView.OnItemClickListener, - AdapterView.OnItemLongClickListener { - @Override - public void onItemClick(AdapterView<?> parent, View view, int position, long id) { - final ListView listView = parent instanceof ListView ? (ListView) parent : null; - if (listView != null) { - position -= listView.getHeaderViewsCount(); - } - if (position < 0) { - // Header views don't count. - return; - } - // If we're still loading, we can't yet enable the buttons. - if (mMultiProfilePagerAdapter.getActiveListAdapter() - .resolveInfoForPosition(position, true) == null) { - return; - } - ListView currentAdapterView = - (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); - final int checkedPos = currentAdapterView.getCheckedItemPosition(); - final boolean hasValidSelection = checkedPos != ListView.INVALID_POSITION; - if (!useLayoutWithDefault() - && (!hasValidSelection || mLastSelected != checkedPos) - && mAlwaysButton != null) { - setAlwaysButtonEnabled(hasValidSelection, checkedPos, true); - mOnceButton.setEnabled(hasValidSelection); - if (hasValidSelection) { - currentAdapterView.smoothScrollToPosition(checkedPos); - mOnceButton.requestFocus(); - } - mLastSelected = checkedPos; - } else { - startSelected(position, false, true); - } - } - - @Override - public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { - final ListView listView = parent instanceof ListView ? (ListView) parent : null; - if (listView != null) { - position -= listView.getHeaderViewsCount(); - } - if (position < 0) { - // Header views don't count. - return false; - } - ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() - .resolveInfoForPosition(position, true); - showTargetDetails(ri); - return true; - } - - } - - private void setupProfileTabs() { - maybeHideDivider(); - - TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - - mMultiProfilePagerAdapter.setupProfileTabs( - getLayoutInflater(), - tabHost, - viewPager, - R.layout.resolver_profile_tab_button, - com.android.internal.R.id.profile_pager, - () -> onProfileTabSelected(viewPager.getCurrentItem()), - new OnProfileSelectedListener() { - @Override - public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { - resetButtonBar(); - resetCheckedItem(); - } - - @Override - public void onProfilePageStateChanged(int state) {} - }); - mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = - tabHost.getTabWidget().getChildAt( - mMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }; - } - - static final class PickTargetOptionRequest extends PickOptionRequest { - public PickTargetOptionRequest(@Nullable Prompt prompt, Option[] options, - @Nullable Bundle extras) { - super(prompt, options, extras); - } - - @Override - public void onCancel() { - super.onCancel(); - final ResolverActivity ra = (ResolverActivity) getActivity(); - if (ra != null) { - ra.mPickOptionRequest = null; - ra.finish(); - } - } - - @Override - public void onPickOptionResult(boolean finished, Option[] selections, Bundle result) { - super.onPickOptionResult(finished, selections, result); - if (selections.length != 1) { - // TODO In a better world we would filter the UI presented here and let the - // user refine. Maybe later. - return; - } - - final ResolverActivity ra = (ResolverActivity) getActivity(); - if (ra != null) { - final TargetInfo ti = ra.mMultiProfilePagerAdapter.getActiveListAdapter() - .getItem(selections[0].getIndex()); - if (ra.onTargetSelected(ti, false)) { - ra.mPickOptionRequest = null; - ra.finish(); - } - } - } - } - /** - * Returns the {@link UserHandle} to use when querying resolutions for intents in a - * {@link ResolverListController} configured for the provided {@code userHandle}. - */ - protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return requireAnnotatedUserHandles().getQueryIntentsUser(userHandle); - } - - /** - * Returns the {@link List} of {@link UserHandle} to pass on to the - * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. - */ - @VisibleForTesting(visibility = PROTECTED) - public final List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) { - return getResolverRankerServiceUserHandleListInternal(userHandle); - } - - @VisibleForTesting - protected List<UserHandle> getResolverRankerServiceUserHandleListInternal( - UserHandle userHandle) { - List<UserHandle> userList = new ArrayList<>(); - userList.add(userHandle); - // Add clonedProfileUserHandle to the list only if we are: - // a. Building the Personal Tab. - // b. CloneProfile exists on the device. - if (userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) - && hasCloneProfile()) { - userList.add(requireAnnotatedUserHandles().cloneProfileUserHandle); - } - return userList; - } - - private CharSequence getOrLoadDisplayLabel(TargetInfo info) { - if (info.isDisplayResolveInfo()) { - mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); - } - CharSequence displayLabel = info.getDisplayLabel(); - return displayLabel == null ? "" : displayLabel; - } -} diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt deleted file mode 100644 index 7eb63ab3..00000000 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.android.intentresolver.v2 - -import androidx.activity.ComponentActivity -import androidx.annotation.OpenForTesting - -/** Activity logic for [ResolverActivity]. */ -@OpenForTesting -open class ResolverActivityLogic( - tag: String, - activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, -) : - ActivityLogic, - CommonActivityLogic by CommonActivityLogicImpl( - tag, - activity, - onWorkProfileStatusUpdated, - ) diff --git a/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt b/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt deleted file mode 100644 index 1a58afcb..00000000 --- a/java/src/com/android/intentresolver/v2/data/BroadcastFlow.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.android.intentresolver.v2.data - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.UserHandle -import android.util.Log -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.onFailure -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -private const val TAG = "BroadcastFlow" - -/** - * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new - * value whenever broadcast matching _filter_ is received. The result value will be computed using - * [transform] and emitted if non-null. - */ -internal fun <T> broadcastFlow( - context: Context, - filter: IntentFilter, - user: UserHandle, - transform: (Intent) -> T? -): Flow<T> = callbackFlow { - val receiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - transform(intent)?.also { result -> - trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } - } - ?: Log.w(TAG, "Ignored broadcast $intent") - } - } - - context.registerReceiverAsUser( - receiver, - user, - IntentFilter(filter), - null, - null, - Context.RECEIVER_NOT_EXPORTED - ) - awaitClose { context.unregisterReceiver(receiver) } -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt deleted file mode 100644 index a0b2d1ef..00000000 --- a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.android.intentresolver.v2.data.repository - -import android.content.pm.UserInfo -import com.android.intentresolver.v2.shared.model.User -import com.android.intentresolver.v2.shared.model.User.Role - -/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */ -fun UserInfo.getSupportedUserRole(): Role? = - when { - isFull -> Role.PERSONAL - isManagedProfile -> Role.WORK - isCloneProfile -> Role.CLONE - isPrivateProfile -> Role.PRIVATE - else -> null - } - -/** - * Creates a [User], based on values from a [UserInfo]. - * - * ``` - * val users: List<User> = - * getEnabledProfiles(user).map(::toUser).filterNotNull() - * ``` - * - * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null - */ -fun UserInfo.toUser(): User? { - return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt deleted file mode 100644 index b57609e5..00000000 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ /dev/null @@ -1,252 +0,0 @@ -package com.android.intentresolver.v2.data.repository - -import android.content.Context -import android.content.Intent -import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE -import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE -import android.content.Intent.ACTION_PROFILE_ADDED -import android.content.Intent.ACTION_PROFILE_AVAILABLE -import android.content.Intent.ACTION_PROFILE_REMOVED -import android.content.Intent.ACTION_PROFILE_UNAVAILABLE -import android.content.Intent.EXTRA_QUIET_MODE -import android.content.Intent.EXTRA_USER -import android.content.IntentFilter -import android.content.pm.UserInfo -import android.os.UserHandle -import android.os.UserManager -import android.util.Log -import androidx.annotation.VisibleForTesting -import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.Main -import com.android.intentresolver.inject.ProfileParent -import com.android.intentresolver.v2.data.broadcastFlow -import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent -import com.android.intentresolver.v2.shared.model.User -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.runningFold -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext - -interface UserRepository { - /** - * A [Flow] user profile groups. Each list contains the context user along with all members of - * the profile group. This includes the (Full) parent user, if the context user is a profile. - */ - val users: Flow<List<User>> - - /** - * A [Flow] of availability. Only profile users may become unavailable. - * - * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. - */ - val availability: Flow<Map<User, Boolean>> - - /** - * Request that availability be updated to the requested state. This currently includes toggling - * quiet mode as needed. This may involve additional background actions, such as starting or - * stopping a profile user (along with their many associated processes). - * - * If successful, the change will be applied after the call returns and can be observed using - * [UserRepository.isAvailable] for the given user. - * - * No actions are taken if the user is already in requested state. - * - * @throws IllegalArgumentException if called for an unsupported user type - */ - suspend fun requestState(user: User, available: Boolean) -} - -private const val TAG = "UserRepository" - -private data class UserWithState(val user: User, val available: Boolean) - -private typealias UserStates = List<UserWithState> - -/** Tracks and publishes state for the parent user and associated profiles. */ -class UserRepositoryImpl -@VisibleForTesting -constructor( - private val profileParent: UserHandle, - private val userManager: UserManager, - /** A flow of events which represent user-state changes from [UserManager]. */ - private val userEvents: Flow<UserEvent>, - scope: CoroutineScope, - private val backgroundDispatcher: CoroutineDispatcher -) : UserRepository { - @Inject - constructor( - @ApplicationContext context: Context, - @ProfileParent profileParent: UserHandle, - userManager: UserManager, - @Main scope: CoroutineScope, - @Background background: CoroutineDispatcher - ) : this( - profileParent, - userManager, - userEvents = userBroadcastFlow(context, profileParent), - scope, - background - ) - - data class UserEvent(val action: String, val user: UserHandle, val quietMode: Boolean = false) - - /** - * An exception which indicates that an inconsistency exists between the user state map and the - * rest of the system. - */ - internal class UserStateException( - override val message: String, - val event: UserEvent, - override val cause: Throwable? = null - ) : RuntimeException("$message: event=$event", cause) - - private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) - private val usersWithState: Flow<UserStates> = - userEvents - .onStart { emit(UserEvent(INITIALIZE, profileParent)) } - .onEach { Log.i(TAG, "userEvent: $it") } - .runningFold<UserEvent, UserStates>(emptyList()) { users, event -> - try { - // Handle an action by performing some operation, then returning a new map - when (event.action) { - INITIALIZE -> createNewUserStates(profileParent) - ACTION_PROFILE_ADDED -> handleProfileAdded(event, users) - ACTION_PROFILE_REMOVED -> handleProfileRemoved(event, users) - ACTION_MANAGED_PROFILE_UNAVAILABLE, - ACTION_MANAGED_PROFILE_AVAILABLE, - ACTION_PROFILE_AVAILABLE, - ACTION_PROFILE_UNAVAILABLE -> handleAvailability(event, users) - else -> { - Log.w(TAG, "Unhandled event: $event)") - users - } - } - } catch (e: UserStateException) { - Log.e(TAG, "An error occurred handling an event: ${e.event}", e) - Log.e(TAG, "Attempting to recover...") - createNewUserStates(profileParent) - } - } - .distinctUntilChanged() - .onEach { Log.i(TAG, "userStateList: $it") } - .stateIn(sharingScope, SharingStarted.Eagerly, emptyList()) - .filterNot { it.isEmpty() } - - override val users: Flow<List<User>> = - usersWithState.map { userStateMap -> userStateMap.map { it.user } }.distinctUntilChanged() - - override val availability: Flow<Map<User, Boolean>> = - usersWithState - .map { list -> list.associate { it.user to it.available } } - .distinctUntilChanged() - - override suspend fun requestState(user: User, available: Boolean) { - require(user.type == User.Type.PROFILE) { "Only profile users are supported" } - return withContext(backgroundDispatcher) { - Log.i(TAG, "requestQuietModeEnabled: ${!available} for user $user") - userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) - } - } - - private fun List<UserWithState>.update(handle: UserHandle, user: UserWithState) = - filter { it.user.id != handle.identifier } + user - - private fun handleAvailability(event: UserEvent, current: UserStates): UserStates { - val userEntry = - current.firstOrNull { it.user.id == event.user.identifier } - ?: throw UserStateException("User was not present in the map", event) - return current.update(event.user, userEntry.copy(available = !event.quietMode)) - } - - private fun handleProfileRemoved(event: UserEvent, current: UserStates): UserStates { - if (!current.any { it.user.id == event.user.identifier }) { - throw UserStateException("User was not present in the map", event) - } - return current.filter { it.user.id != event.user.identifier } - } - - private suspend fun handleProfileAdded(event: UserEvent, current: UserStates): UserStates { - val user = - try { - requireNotNull(readUser(event.user)) - } catch (e: Exception) { - throw UserStateException("Failed to read user from UserManager", event, e) - } - return current + UserWithState(user, !event.quietMode) - } - - private suspend fun createNewUserStates(user: UserHandle): UserStates { - val profiles = readProfileGroup(user) - return profiles.mapNotNull { userInfo -> - userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } - } - } - - private suspend fun readProfileGroup(member: UserHandle): List<UserInfo> { - return withContext(backgroundDispatcher) { - @Suppress("DEPRECATION") userManager.getEnabledProfiles(member.identifier) - } - .toList() - } - - /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ - private suspend fun readUser(user: UserHandle): User? { - val userInfo = - withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } - return userInfo?.let { info -> - info.getSupportedUserRole()?.let { role -> User(info.id, role) } - } - } -} - -/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */ -private fun Intent.toUserEvent(): UserEvent? { - val action = action - val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java) - val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) ?: false - return if (user == null || action == null) { - null - } else { - UserEvent(action, user, quietMode) - } -} - -const val INITIALIZE = "INITIALIZE" - -private fun createFilter(actions: Iterable<String>): IntentFilter { - return IntentFilter().apply { actions.forEach(::addAction) } -} - -private fun UserInfo?.isAvailable(): Boolean { - return this?.isQuietModeEnabled != true -} - -private fun userBroadcastFlow(context: Context, profileParent: UserHandle): Flow<UserEvent> { - val userActions = - setOf( - ACTION_PROFILE_ADDED, - ACTION_PROFILE_REMOVED, - - // Quiet mode enabled/disabled for managed - // From: UserController.broadcastProfileAvailabilityChanges - // In response to setQuietModeEnabled - ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only - ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only - - // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile - // true' - ACTION_PROFILE_AVAILABLE, // quiet mode, - ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type - ) - return broadcastFlow(context, createFilter(userActions), profileParent, Intent::toUserEvent) -} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt deleted file mode 100644 index 3553744a..00000000 --- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.android.intentresolver.v2.data.repository - -import android.content.Context -import androidx.core.content.getSystemService -import com.android.intentresolver.v2.shared.model.User - -/** - * Provides cached instances of a [system service][Context.getSystemService] created with - * [the context of a specified user][Context.createContextAsUser]. - * - * System services which have only `@UserHandleAware` APIs operate on the user id available from - * [Context.getUser], the context used to retrieve the service. This utility helps adapt a per-user - * API model to work in multi-user manner. - * - * Example usage: - * ``` - * val usageStats = userScopedService<UsageStatsManager>(context) - * - * fun getStatsForUser( - * user: User, - * from: Long, - * to: Long - * ): UsageStats { - * return usageStats.forUser(user) - * .queryUsageStats(INTERVAL_BEST, from, to) - * } - * ``` - */ -interface UserScopedService<T> { - fun forUser(user: User): T -} - -inline fun <reified T> userScopedService(context: Context): UserScopedService<T> { - return object : UserScopedService<T> { - private val map = mutableMapOf<User, T>() - - override fun forUser(user: User): T { - return synchronized(this) { - map.getOrPut(user) { - val userContext = context.createContextAsUser(user.handle, 0) - requireNotNull(userContext.getSystemService()) - } - } - } - } -} diff --git a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java deleted file mode 100644 index 2f1e1b59..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelper.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.emptystate; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import com.android.intentresolver.emptystate.EmptyState; -import com.android.internal.annotations.VisibleForTesting; - -import java.util.Optional; -import java.util.function.Supplier; - -/** - * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by - * some empty-state status. - */ -public class EmptyStateUiHelper { - private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier; - private final View mEmptyStateView; - private final View mListView; - private final View mEmptyStateContainerView; - private final TextView mEmptyStateTitleView; - private final TextView mEmptyStateSubtitleView; - private final Button mEmptyStateButtonView; - private final View mEmptyStateProgressView; - private final View mEmptyStateEmptyView; - - public EmptyStateUiHelper( - ViewGroup rootView, - int listViewResourceId, - Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { - mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; - mEmptyStateView = - rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); - mListView = rootView.requireViewById(listViewResourceId); - mEmptyStateContainerView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_container); - mEmptyStateTitleView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_title); - mEmptyStateSubtitleView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_subtitle); - mEmptyStateButtonView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_button); - mEmptyStateProgressView = mEmptyStateView.requireViewById( - com.android.internal.R.id.resolver_empty_state_progress); - mEmptyStateEmptyView = mEmptyStateView.requireViewById(com.android.internal.R.id.empty); - } - - /** - * Display the described empty state. - * @param emptyState the data describing the cause of this empty-state condition. - * @param buttonOnClick handler for a button that the user might be able to use to circumvent - * the empty-state condition. If null, no button will be displayed. - */ - public void showEmptyState(EmptyState emptyState, View.OnClickListener buttonOnClick) { - resetViewVisibilities(); - setupContainerPadding(); - - String title = emptyState.getTitle(); - if (title != null) { - mEmptyStateTitleView.setVisibility(View.VISIBLE); - mEmptyStateTitleView.setText(title); - } else { - mEmptyStateTitleView.setVisibility(View.GONE); - } - - String subtitle = emptyState.getSubtitle(); - if (subtitle != null) { - mEmptyStateSubtitleView.setVisibility(View.VISIBLE); - mEmptyStateSubtitleView.setText(subtitle); - } else { - mEmptyStateSubtitleView.setVisibility(View.GONE); - } - - mEmptyStateEmptyView.setVisibility( - emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); - // TODO: The EmptyState API says that if `useDefaultEmptyView()` is true, we'll ignore the - // state's specified title/subtitle; where (if anywhere) is that implemented? - - mEmptyStateButtonView.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); - mEmptyStateButtonView.setOnClickListener(buttonOnClick); - - // Don't show the main list view when we're showing an empty state. - mListView.setVisibility(View.GONE); - } - - /** Sets up the padding of the view containing the empty state screens. */ - public void setupContainerPadding() { - Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); - bottomPaddingOverride.ifPresent(paddingBottom -> - mEmptyStateContainerView.setPadding( - mEmptyStateContainerView.getPaddingLeft(), - mEmptyStateContainerView.getPaddingTop(), - mEmptyStateContainerView.getPaddingRight(), - paddingBottom)); - } - - public void showSpinner() { - mEmptyStateTitleView.setVisibility(View.INVISIBLE); - // TODO: subtitle? - mEmptyStateButtonView.setVisibility(View.INVISIBLE); - mEmptyStateProgressView.setVisibility(View.VISIBLE); - mEmptyStateEmptyView.setVisibility(View.GONE); - } - - public void hide() { - mEmptyStateView.setVisibility(View.GONE); - mListView.setVisibility(View.VISIBLE); - } - - // TODO: this is exposed for testing so we can thoroughly prepare initial conditions that let us - // observe the resulting change. In reality it's only invoked as part of `showEmptyState()` and - // we could consider setting up narrower "realistic" preconditions to make assertions about the - // higher-level operation. - @VisibleForTesting - void resetViewVisibilities() { - mEmptyStateTitleView.setVisibility(View.VISIBLE); - mEmptyStateSubtitleView.setVisibility(View.VISIBLE); - mEmptyStateButtonView.setVisibility(View.INVISIBLE); - mEmptyStateProgressView.setVisibility(View.GONE); - mEmptyStateEmptyView.setVisibility(View.GONE); - mEmptyStateView.setVisibility(View.VISIBLE); - } -} - diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java deleted file mode 100644 index dfc46697..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.emptystate; - -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.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 androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.intentresolver.R; -import com.android.intentresolver.ResolvedComponentInfo; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; - -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 UserHandle mTabOwnerUserHandleForLaunch; - - public NoAppsAvailableEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @Nullable UserHandle personalProfileUserHandle, @NonNull String metricsCategory, - @NonNull UserHandle tabOwnerUserHandleForLaunch) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mPersonalProfileUserHandle = personalProfileUserHandle; - mMetricsCategory = metricsCategory; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; - } - - @Nullable - @Override - @SuppressWarnings("ReferenceEquality") - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - UserHandle listUserHandle = resolverListAdapter.getUserHandle(); - - if (mWorkProfileUserHandle != null - && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) - || !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<ResolvedComponentInfo> resolversForIntent = - adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); - for (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 final String mTitle; - - @NonNull - private final String mMetricsCategory; - - private final boolean mIsPersonalProfile; - - public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory, - boolean isPersonalProfile) { - mTitle = title; - mMetricsCategory = metricsCategory; - mIsPersonalProfile = isPersonalProfile; - } - - @NonNull - @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(); - } - } -} diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java deleted file mode 100644 index b744c589..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/NoCrossProfileEmptyStateProvider.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.emptystate; - -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.os.UserHandle; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; - -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; - -/** - * 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 UserHandle mTabOwnerUserHandleForLaunch; - - public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, - EmptyState noWorkToPersonalEmptyState, - EmptyState noPersonalToWorkEmptyState, - CrossProfileIntentsChecker crossProfileIntentsChecker, - UserHandle tabOwnerUserHandleForLaunch) { - mPersonalProfileUserHandle = personalUserHandle; - mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; - mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; - mCrossProfileIntentsChecker = crossProfileIntentsChecker; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - boolean shouldShowBlocker = - !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) - && !mCrossProfileIntentsChecker - .hasCrossProfileIntents(resolverListAdapter.getIntents(), - mTabOwnerUserHandleForLaunch.getIdentifier(), - 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(@NonNull Context context, - String devicePolicyStringTitleId, @StringRes int defaultTitleResource, - String devicePolicyStringSubtitleId, @StringRes int defaultSubtitleResource, - int devicePolicyEventId, @NonNull 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/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java deleted file mode 100644 index eaed35a7..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.emptystate; - -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; - -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 androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.WorkProfileAvailabilityManager; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; - -/** - * 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 ResolverWorkProfilePausedEmptyStateProvider implements EmptyStateProvider { - - private final UserHandle mWorkProfileUserHandle; - private final WorkProfileAvailabilityManager mWorkProfileAvailability; - private final String mMetricsCategory; - private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - private final Context mContext; - - public ResolverWorkProfilePausedEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @NonNull WorkProfileAvailabilityManager workProfileAvailability, - @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, - @NonNull String metricsCategory) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mWorkProfileAvailability = workProfileAvailability; - mMetricsCategory = metricsCategory; - mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) - || !mWorkProfileAvailability.isQuietModeEnabled() - || 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(); - } - mWorkProfileAvailability.requestQuietModeEnabled(false); - }, 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/v2/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java deleted file mode 100644 index a6fee3ec..00000000 --- a/java/src/com/android/intentresolver/v2/emptystate/WorkProfilePausedEmptyStateProvider.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.emptystate; - -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; - -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 androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.intentresolver.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.WorkProfileAvailabilityManager; -import com.android.intentresolver.emptystate.EmptyState; -import com.android.intentresolver.emptystate.EmptyStateProvider; - -/** - * 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 WorkProfileAvailabilityManager mWorkProfileAvailability; - private final String mMetricsCategory; - private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - private final Context mContext; - - public WorkProfilePausedEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @NonNull WorkProfileAvailabilityManager workProfileAvailability, - @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, - @NonNull String metricsCategory) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mWorkProfileAvailability = workProfileAvailability; - mMetricsCategory = metricsCategory; - mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) - || !mWorkProfileAvailability.isQuietModeEnabled() - || 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(); - } - mWorkProfileAvailability.requestQuietModeEnabled(false); - }, 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/v2/listcontroller/FilterableComponents.kt b/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt deleted file mode 100644 index 5855e2fc..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/FilterableComponents.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import com.android.intentresolver.ChooserRequestParameters - -/** A class that is able to identify components that should be hidden from the user. */ -interface FilterableComponents { - /** Whether this component should hidden from the user. */ - fun isComponentFiltered(name: ComponentName): Boolean -} - -/** A class that never filters components. */ -class NoComponentFiltering : FilterableComponents { - override fun isComponentFiltered(name: ComponentName): Boolean = false -} - -/** A class that filters components by chooser request filter. */ -class ChooserRequestFilteredComponents( - private val chooserRequestParameters: ChooserRequestParameters, -) : FilterableComponents { - override fun isComponentFiltered(name: ComponentName): Boolean = - chooserRequestParameters.filteredComponentNames.contains(name) -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt b/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt deleted file mode 100644 index bb9394b4..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/IntentResolver.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.content.Intent -import android.content.pm.PackageManager -import android.os.UserHandle -import com.android.intentresolver.ResolvedComponentInfo - -/** A class for translating [Intent]s to [ResolvedComponentInfo]s. */ -interface IntentResolver { - /** - * Get data about all the ways the user with the specified handle can resolve any of the - * provided `intents`. - */ - fun getResolversForIntentAsUser( - shouldGetResolvedFilter: Boolean, - shouldGetActivityMetadata: Boolean, - shouldGetOnlyDefaultActivities: Boolean, - intents: List<Intent>, - userHandle: UserHandle, - ): List<ResolvedComponentInfo> -} - -/** Resolves [Intent]s using the [packageManager], deduping using the given [ResolveListDeduper]. */ -class IntentResolverImpl( - private val packageManager: PackageManager, - resolveListDeduper: ResolveListDeduper, -) : IntentResolver, ResolveListDeduper by resolveListDeduper { - override fun getResolversForIntentAsUser( - shouldGetResolvedFilter: Boolean, - shouldGetActivityMetadata: Boolean, - shouldGetOnlyDefaultActivities: Boolean, - intents: List<Intent>, - userHandle: UserHandle, - ): List<ResolvedComponentInfo> { - val baseFlags = - ((if (shouldGetOnlyDefaultActivities) PackageManager.MATCH_DEFAULT_ONLY else 0) or - PackageManager.MATCH_DIRECT_BOOT_AWARE or - PackageManager.MATCH_DIRECT_BOOT_UNAWARE or - (if (shouldGetResolvedFilter) PackageManager.GET_RESOLVED_FILTER else 0) or - (if (shouldGetActivityMetadata) PackageManager.GET_META_DATA else 0) or - PackageManager.MATCH_CLONE_PROFILE) - return getResolversForIntentAsUserInternal( - intents, - userHandle, - baseFlags, - ) - } - - private fun getResolversForIntentAsUserInternal( - intents: List<Intent>, - userHandle: UserHandle, - baseFlags: Int, - ): List<ResolvedComponentInfo> = buildList { - for (intent in intents) { - var flags = baseFlags - if (intent.isWebIntent || intent.flags and Intent.FLAG_ACTIVITY_MATCH_EXTERNAL != 0) { - flags = flags or PackageManager.MATCH_INSTANT - } - // Because of AIDL bug, queryIntentActivitiesAsUser can't accept subclasses of Intent. - val fixedIntent = - if (intent.javaClass != Intent::class.java) { - Intent(intent) - } else { - intent - } - val infos = packageManager.queryIntentActivitiesAsUser(fixedIntent, flags, userHandle) - addToResolveListWithDedupe(this, fixedIntent, infos) - } - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt b/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt deleted file mode 100644 index b2856526..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/LastChosenManager.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.app.AppGlobals -import android.content.ContentResolver -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.IPackageManager -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import android.os.RemoteException -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext - -/** Class that stores and retrieves the most recently chosen resolutions. */ -interface LastChosenManager { - - /** Returns the most recently chosen resolution. */ - suspend fun getLastChosen(): ResolveInfo - - /** Sets the most recently chosen resolution. */ - suspend fun setLastChosen(intent: Intent, filter: IntentFilter, match: Int) -} - -/** - * Stores and retrieves the most recently chosen resolutions using the [PackageManager] provided by - * the [packageManagerProvider]. - */ -class PackageManagerLastChosenManager( - private val contentResolver: ContentResolver, - private val bgDispatcher: CoroutineDispatcher, - private val targetIntent: Intent, - private val packageManagerProvider: () -> IPackageManager = AppGlobals::getPackageManager, -) : LastChosenManager { - - @Throws(RemoteException::class) - override suspend fun getLastChosen(): ResolveInfo { - return withContext(bgDispatcher) { - packageManagerProvider() - .getLastChosenActivity( - targetIntent, - targetIntent.resolveTypeIfNeeded(contentResolver), - PackageManager.MATCH_DEFAULT_ONLY, - ) - } - } - - @Throws(RemoteException::class) - override suspend fun setLastChosen(intent: Intent, filter: IntentFilter, match: Int) { - return withContext(bgDispatcher) { - packageManagerProvider() - .setLastChosenActivity( - intent, - intent.resolveType(contentResolver), - PackageManager.MATCH_DEFAULT_ONLY, - filter, - match, - intent.component, - ) - } - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt b/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt deleted file mode 100644 index cae2af95..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/PermissionChecker.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.app.ActivityManager -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext - -/** Class for checking if a permission has been granted. */ -interface PermissionChecker { - /** Checks if the given [permission] has been granted. */ - suspend fun checkComponentPermission( - permission: String, - uid: Int, - owningUid: Int, - exported: Boolean, - ): Int -} - -/** - * Class for checking if a permission has been granted using the static - * [ActivityManager.checkComponentPermission]. - */ -class ActivityManagerPermissionChecker( - private val bgDispatcher: CoroutineDispatcher, -) : PermissionChecker { - override suspend fun checkComponentPermission( - permission: String, - uid: Int, - owningUid: Int, - exported: Boolean, - ): Int = - withContext(bgDispatcher) { - ActivityManager.checkComponentPermission(permission, uid, owningUid, exported) - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt b/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt deleted file mode 100644 index 8be45ba2..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/PinnableComponents.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.SharedPreferences - -/** A class that is able to identify components that should be pinned for the user. */ -interface PinnableComponents { - /** Whether this component is pinned by the user. */ - fun isComponentPinned(name: ComponentName): Boolean -} - -/** A class that never pins components. */ -class NoComponentPinning : PinnableComponents { - override fun isComponentPinned(name: ComponentName): Boolean = false -} - -/** A class that determines pinnable components by user preferences. */ -class SharedPreferencesPinnedComponents( - private val pinnedSharedPreferences: SharedPreferences, -) : PinnableComponents { - override fun isComponentPinned(name: ComponentName): Boolean = - pinnedSharedPreferences.getBoolean(name.flattenToString(), false) -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt deleted file mode 100644 index f0b4bf3f..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduper.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ResolveInfo -import android.util.Log -import com.android.intentresolver.ResolvedComponentInfo - -/** A class for adding [ResolveInfo]s to a list of [ResolvedComponentInfo]s without duplicates. */ -interface ResolveListDeduper { - /** - * Adds [ResolveInfo]s in [from] to [ResolvedComponentInfo]s in [into], creating new - * [ResolvedComponentInfo]s when there is not already a corresponding one. - * - * This method may be destructive to both the given [into] list and the underlying - * [ResolvedComponentInfo]s. - */ - fun addToResolveListWithDedupe( - into: MutableList<ResolvedComponentInfo>, - intent: Intent, - from: List<ResolveInfo>, - ) -} - -/** - * Default implementation for adding [ResolveInfo]s to a list of [ResolvedComponentInfo]s without - * duplicates. Uses the given [PinnableComponents] to determine the pinning state of newly created - * [ResolvedComponentInfo]s. - */ -class ResolveListDeduperImpl(pinnableComponents: PinnableComponents) : - ResolveListDeduper, PinnableComponents by pinnableComponents { - override fun addToResolveListWithDedupe( - into: MutableList<ResolvedComponentInfo>, - intent: Intent, - from: List<ResolveInfo>, - ) { - from.forEach { newInfo -> - if (newInfo.userHandle == null) { - Log.w(TAG, "Skipping ResolveInfo with no userHandle: $newInfo") - return@forEach - } - val oldInfo = into.firstOrNull { isSameResolvedComponent(newInfo, it) } - // If existing resolution found, add to existing and filter out - if (oldInfo != null) { - oldInfo.add(intent, newInfo) - } else { - with(newInfo.activityInfo) { - into.add( - ResolvedComponentInfo( - ComponentName(packageName, name), - intent, - newInfo, - ) - .apply { isPinned = isComponentPinned(name) }, - ) - } - } - } - } - - private fun isSameResolvedComponent(a: ResolveInfo, b: ResolvedComponentInfo): Boolean { - val ai = a.activityInfo - return ai.packageName == b.name.packageName && ai.name == b.name.className - } - - companion object { - const val TAG = "ResolveListDeduper" - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt deleted file mode 100644 index e78bff00..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFiltering.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.content.pm.PackageManager -import android.util.Log -import com.android.intentresolver.ResolvedComponentInfo -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope - -/** Provides filtering methods for lists of [ResolvedComponentInfo]. */ -interface ResolvedComponentFiltering { - /** - * Returns a list with all the [ResolvedComponentInfo] in [inputList], less the ones that are - * not eligible. - */ - suspend fun filterIneligibleActivities( - inputList: List<ResolvedComponentInfo>, - ): List<ResolvedComponentInfo> - - /** Filter out any low priority items. */ - fun filterLowPriority(inputList: List<ResolvedComponentInfo>): List<ResolvedComponentInfo> -} - -/** - * Default instantiation of the filtering methods for lists of [ResolvedComponentInfo]. - * - * Binder calls are performed on the given [bgDispatcher] and permissions are checked as if launched - * from the given [launchedFromUid] UID. Component filtering is handled by the given - * [FilterableComponents] and permission checking is handled by the given [PermissionChecker]. - */ -class ResolvedComponentFilteringImpl( - private val launchedFromUid: Int, - filterableComponents: FilterableComponents, - permissionChecker: PermissionChecker, -) : - ResolvedComponentFiltering, - PermissionChecker by permissionChecker, - FilterableComponents by filterableComponents { - constructor( - bgDispatcher: CoroutineDispatcher, - launchedFromUid: Int, - filterableComponents: FilterableComponents, - ) : this( - launchedFromUid = launchedFromUid, - filterableComponents = filterableComponents, - permissionChecker = ActivityManagerPermissionChecker(bgDispatcher), - ) - - /** - * Filter out items that are filtered by [FilterableComponents] or do not have the necessary - * permissions. - */ - override suspend fun filterIneligibleActivities( - inputList: List<ResolvedComponentInfo>, - ): List<ResolvedComponentInfo> = coroutineScope { - inputList - .map { - val activityInfo = it.getResolveInfoAt(0).activityInfo - if (isComponentFiltered(activityInfo.componentName)) { - CompletableDeferred(value = null) - } else { - // Do all permission checks in parallel - async { - val granted = - checkComponentPermission( - activityInfo.permission, - launchedFromUid, - activityInfo.applicationInfo.uid, - activityInfo.exported, - ) == PackageManager.PERMISSION_GRANTED - if (granted) it else null - } - } - } - .awaitAll() - .filterNotNull() - } - - /** - * Filters out all elements starting with the first elements with a different priority or - * default status than the first element. - */ - override fun filterLowPriority( - inputList: List<ResolvedComponentInfo>, - ): List<ResolvedComponentInfo> { - val firstResolveInfo = inputList[0].getResolveInfoAt(0) - // Only display the first matches that are either of equal - // priority or have asked to be default options. - val firstDiffIndex = - inputList.indexOfFirst { resolvedComponentInfo -> - val resolveInfo = resolvedComponentInfo.getResolveInfoAt(0) - if (firstResolveInfo == resolveInfo) { - false - } else { - if (DEBUG) { - Log.v( - TAG, - "${firstResolveInfo?.activityInfo?.name}=" + - "${firstResolveInfo?.priority}/${firstResolveInfo?.isDefault}" + - " vs ${resolveInfo?.activityInfo?.name}=" + - "${resolveInfo?.priority}/${resolveInfo?.isDefault}" - ) - } - firstResolveInfo!!.priority != resolveInfo!!.priority || - firstResolveInfo.isDefault != resolveInfo.isDefault - } - } - return if (firstDiffIndex == -1) { - inputList - } else { - inputList.subList(0, firstDiffIndex) - } - } - - companion object { - private const val TAG = "ResolvedComponentFilter" - private const val DEBUG = false - } -} diff --git a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt b/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt deleted file mode 100644 index 8ab41ef0..00000000 --- a/java/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSorting.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.android.intentresolver.v2.listcontroller - -import android.os.UserHandle -import android.util.Log -import com.android.intentresolver.ResolvedComponentInfo -import com.android.intentresolver.chooser.DisplayResolveInfo -import com.android.intentresolver.chooser.TargetInfo -import com.android.intentresolver.model.AbstractResolverComparator -import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext - -/** Provides sorting methods for lists of [ResolvedComponentInfo]. */ -interface ResolvedComponentSorting { - /** Returns the a copy of the [inputList] sorted by app share score. */ - suspend fun sorted(inputList: List<ResolvedComponentInfo>?): List<ResolvedComponentInfo>? - - /** Returns the app share score of the [target]. */ - fun getScore(target: DisplayResolveInfo): Float - - /** Returns the app share score of the [targetInfo]. */ - fun getScore(targetInfo: TargetInfo): Float - - /** Updates the model about [targetInfo]. */ - suspend fun updateModel(targetInfo: TargetInfo) - - /** Updates the model about Activity selection. */ - suspend fun updateChooserCounts(packageName: String, user: UserHandle, action: String) - - /** Cleans up resources. Nothing should be called after calling this. */ - fun destroy() -} - -/** - * Provides sorting methods using the given [resolverComparator]. - * - * Long calculations and binder calls are performed on the given [bgDispatcher]. - */ -class ResolvedComponentSortingImpl( - private val bgDispatcher: CoroutineDispatcher, - private val resolverComparator: AbstractResolverComparator, -) : ResolvedComponentSorting { - - private val computeComplete = AtomicReference<CompletableDeferred<Unit>?>(null) - - @Throws(InterruptedException::class) - private suspend fun computeIfNeeded(inputList: List<ResolvedComponentInfo>) { - if (computeComplete.compareAndSet(null, CompletableDeferred())) { - resolverComparator.setCallBack { computeComplete.get()!!.complete(Unit) } - resolverComparator.compute(inputList) - } - with(computeComplete.get()!!) { if (isCompleted) return else return await() } - } - - override suspend fun sorted( - inputList: List<ResolvedComponentInfo>?, - ): List<ResolvedComponentInfo>? { - if (inputList.isNullOrEmpty()) return inputList - - return withContext(bgDispatcher) { - try { - val beforeRank = System.currentTimeMillis() - computeIfNeeded(inputList) - val sorted = inputList.sortedWith(resolverComparator) - val afterRank = System.currentTimeMillis() - if (DEBUG) { - Log.d(TAG, "Time Cost: ${afterRank - beforeRank}") - } - sorted - } catch (e: InterruptedException) { - Log.e(TAG, "Compute & Sort was interrupted: $e") - null - } - } - } - - override fun getScore(target: DisplayResolveInfo): Float { - return resolverComparator.getScore(target) - } - - override fun getScore(targetInfo: TargetInfo): Float { - return resolverComparator.getScore(targetInfo) - } - - override suspend fun updateModel(targetInfo: TargetInfo) { - withContext(bgDispatcher) { resolverComparator.updateModel(targetInfo) } - } - - override suspend fun updateChooserCounts( - packageName: String, - user: UserHandle, - action: String, - ) { - withContext(bgDispatcher) { - resolverComparator.updateChooserCounts(packageName, user, action) - } - } - - override fun destroy() { - resolverComparator.destroy() - } - - companion object { - private const val TAG = "ResolvedComponentSort" - private const val DEBUG = false - } -} diff --git a/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt deleted file mode 100644 index 18f47023..00000000 --- a/java/src/com/android/intentresolver/v2/platform/SecureSettingsModule.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.android.intentresolver.v2.platform - -import dagger.Binds -import dagger.Module -import dagger.Reusable -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -interface SecureSettingsModule { - - @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings -} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt deleted file mode 100644 index 8ed2fa29..00000000 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver.v2.ui.viewmodel - -import android.util.Log -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import com.android.intentresolver.inject.ChooserServiceFlags -import com.android.intentresolver.v2.ui.model.ActivityModel -import com.android.intentresolver.v2.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY -import com.android.intentresolver.v2.ui.model.ChooserRequest -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.log -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -private const val TAG = "ChooserViewModel" - -@HiltViewModel -class ChooserViewModel -@Inject -constructor( - args: SavedStateHandle, - flags: ChooserServiceFlags, -) : ViewModel() { - - /** Parcelable-only references provided from the creating Activity */ - val activityModel: ActivityModel = - requireNotNull(args[ACTIVITY_MODEL_KEY]) { - "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" - } - - /** The result of reading and validating the inputs provided in savedState. */ - private val status: ValidationResult<ChooserRequest> = readChooserRequest(activityModel, flags) - - val chooserRequest: ChooserRequest by lazy { - when (status) { - is Valid -> status.value - is Invalid -> error(status.errors) - } - } - - fun init(): Boolean { - Log.i(TAG, "viewModel init") - if (status is Invalid) { - status.errors.forEach { finding -> finding.log(TAG) } - return false - } - Log.i(TAG, "request = $chooserRequest") - return true - } -} diff --git a/java/src/com/android/intentresolver/v2/util/MutableLazy.kt b/java/src/com/android/intentresolver/v2/util/MutableLazy.kt deleted file mode 100644 index 4ce9b7fd..00000000 --- a/java/src/com/android/intentresolver/v2/util/MutableLazy.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.android.intentresolver.v2.util - -import java.util.concurrent.atomic.AtomicReference -import kotlin.reflect.KProperty - -/** A lazy delegate that can be changed to a new lazy or null at any time. */ -class MutableLazy<T>(initializer: () -> T?) : Lazy<T?> { - - override val value: T? - get() = lazy.get()?.value - - private var lazy: AtomicReference<Lazy<T?>?> = AtomicReference(lazy(initializer)) - - override fun isInitialized(): Boolean = lazy.get()?.isInitialized() != false - - operator fun getValue(thisRef: Any?, property: KProperty<*>): T? = - lazy.get()?.getValue(thisRef, property) - - /** Replace the existing lazy logic with the [newLazy] */ - fun setLazy(newLazy: Lazy<T?>?) { - lazy.set(newLazy) - } - - /** Replace the existing lazy logic with a [Lazy] created from the [newInitializer]. */ - fun setLazy(newInitializer: () -> T?) { - lazy.set(lazy(newInitializer)) - } - - /** Set the lazy logic to null. */ - fun clear() { - lazy.set(null) - } -} - -/** Constructs a [MutableLazy] using the given [initializer] */ -fun <T> mutableLazy(initializer: () -> T?) = MutableLazy(initializer) diff --git a/java/src/com/android/intentresolver/v2/validation/Findings.kt b/java/src/com/android/intentresolver/validation/Findings.kt index bdf2f00a..0d62017f 100644 --- a/java/src/com/android/intentresolver/v2/validation/Findings.kt +++ b/java/src/com/android/intentresolver/validation/Findings.kt @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.validation +package com.android.intentresolver.validation import android.util.Log -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING import kotlin.reflect.KClass sealed interface Finding { diff --git a/java/src/com/android/intentresolver/v2/validation/Validation.kt b/java/src/com/android/intentresolver/validation/Validation.kt index 6072ec9f..6ba62e57 100644 --- a/java/src/com/android/intentresolver/v2/validation/Validation.kt +++ b/java/src/com/android/intentresolver/validation/Validation.kt @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.validation +package com.android.intentresolver.validation -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING /** * Provides a mechanism for validating a result from a set of properties. diff --git a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt b/java/src/com/android/intentresolver/validation/ValidationResult.kt index f5c467dc..9685c70d 100644 --- a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt +++ b/java/src/com/android/intentresolver/validation/ValidationResult.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.validation +package com.android.intentresolver.validation sealed interface ValidationResult<T> diff --git a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt index 050bd895..74c48a23 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt +++ b/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt @@ -13,17 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.validation.types +package com.android.intentresolver.validation.types import android.content.Intent import android.net.Uri -import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.Validator -import com.android.intentresolver.v2.validation.ValueIsWrongType +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.Validator +import com.android.intentresolver.validation.ValueIsWrongType class IntentOrUri(override val key: String) : Validator<Intent> { @@ -31,7 +31,6 @@ class IntentOrUri(override val key: String) : Validator<Intent> { source: (String) -> Any?, importance: Importance ): ValidationResult<Intent> { - return when (val value = source(key)) { // An intent, return it. is Intent -> Valid(value) @@ -41,10 +40,11 @@ class IntentOrUri(override val key: String) : Validator<Intent> { is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME)) // No value present. - null -> when (importance) { - Importance.WARNING -> Invalid() // No warnings if optional, but missing - Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class)) - } + null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class)) + } // Some other type. else -> { diff --git a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/validation/types/ParceledArray.kt index 78adfd36..5150ec5e 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt +++ b/java/src/com/android/intentresolver/validation/types/ParceledArray.kt @@ -13,17 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.validation.types +package com.android.intentresolver.validation.types -import android.content.Intent -import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.Validator -import com.android.intentresolver.v2.validation.ValueIsWrongType -import com.android.intentresolver.v2.validation.WrongElementType +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.Validator +import com.android.intentresolver.validation.ValueIsWrongType +import com.android.intentresolver.validation.WrongElementType import kotlin.reflect.KClass import kotlin.reflect.cast @@ -36,13 +35,13 @@ class ParceledArray<T : Any>( source: (String) -> Any?, importance: Importance ): ValidationResult<List<T>> { - return when (val value: Any? = source(key)) { // No value present. - null -> when (importance) { - Importance.WARNING -> Invalid() // No warnings if optional, but missing - Importance.CRITICAL -> Invalid(NoValue(key, importance, elementType)) - } + null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, elementType)) + } // A parcel does not transfer the element type information for parcelable // arrays. This leads to a restored type of Array<Parcelable>, which is // incompatible with Array<T : Parcelable>. diff --git a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/validation/types/SimpleValue.kt index 0105541d..64299e11 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt +++ b/java/src/com/android/intentresolver/validation/types/SimpleValue.kt @@ -13,15 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.validation.types +package com.android.intentresolver.validation.types -import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValidationResult -import com.android.intentresolver.v2.validation.Validator -import com.android.intentresolver.v2.validation.ValueIsWrongType +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.Validator +import com.android.intentresolver.validation.ValueIsWrongType import kotlin.reflect.KClass import kotlin.reflect.cast @@ -37,21 +37,24 @@ class SimpleValue<T : Any>( expected.isInstance(value) -> return Valid(expected.cast(value)) // No value is present. - value == null -> when (importance) { - Importance.WARNING -> Invalid() // No warnings if optional, but missing - Importance.CRITICAL -> Invalid(NoValue(key, importance, expected)) - } + value == null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, expected)) + } // The value is some other type. else -> - Invalid(listOf( - ValueIsWrongType( - key, - importance, - actualType = value::class, - allowedTypes = listOf(expected) + Invalid( + listOf( + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(expected) + ) ) - )) + ) } } } diff --git a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt b/java/src/com/android/intentresolver/validation/types/Validators.kt index 70993b4d..1049f045 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt +++ b/java/src/com/android/intentresolver/validation/types/Validators.kt @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.validation.types +package com.android.intentresolver.validation.types -import com.android.intentresolver.v2.validation.Validator +import com.android.intentresolver.validation.Validator inline fun <reified T : Any> value(key: String): Validator<T> { return SimpleValue(key, T::class) diff --git a/java/src/com/android/intentresolver/widget/ActionRow.kt b/java/src/com/android/intentresolver/widget/ActionRow.kt index 6764d3ae..c1f03751 100644 --- a/java/src/com/android/intentresolver/widget/ActionRow.kt +++ b/java/src/com/android/intentresolver/widget/ActionRow.kt @@ -22,7 +22,9 @@ import android.graphics.drawable.Drawable interface ActionRow { fun setActions(actions: List<Action>) - class Action @JvmOverloads constructor( + 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, diff --git a/java/src/com/android/intentresolver/widget/BadgeTextView.kt b/java/src/com/android/intentresolver/widget/BadgeTextView.kt index b6cadd86..6674d92d 100644 --- a/java/src/com/android/intentresolver/widget/BadgeTextView.kt +++ b/java/src/com/android/intentresolver/widget/BadgeTextView.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.widget import android.content.Context diff --git a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt index 26464ca1..e86de888 100644 --- a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt +++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.widget import android.content.Context diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index 3f0458ee..55418c49 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -24,15 +24,16 @@ interface ImagePreviewView { /** * [ImagePreviewView] progressively prepares views for shared element transition and reports - * each successful preparation with [onTransitionElementReady] call followed by - * closing [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is - * zero or more [onTransitionElementReady] calls followed by the final - * [onAllTransitionElementsReady] call. + * each successful preparation with [onTransitionElementReady] call followed by closing + * [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is zero or + * more [onTransitionElementReady] calls followed by the final [onAllTransitionElementsReady] + * call. */ interface TransitionElementStatusCallback { /** - * Invoked when a view for a shared transition animation element is ready i.e. the image - * is loaded and the view is laid out. + * Invoked when a view for a shared transition animation element is ready i.e. the image is + * loaded and the view is laid out. + * * @param name shared element name. */ fun onTransitionElementReady(name: String) diff --git a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt index a7906001..a8aa633b 100644 --- a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt +++ b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt @@ -26,10 +26,10 @@ internal val RecyclerView.areAllChildrenVisible: Boolean val first = getChildAt(0) val last = getChildAt(count - 1) val itemCount = adapter?.itemCount ?: 0 - return getChildAdapterPosition(first) == 0 - && getChildAdapterPosition(last) == itemCount - 1 - && isFullyVisible(first) - && isFullyVisible(last) + return getChildAdapterPosition(first) == 0 && + getChildAdapterPosition(last) == itemCount - 1 && + isFullyVisible(first) && + isFullyVisible(last) } private fun RecyclerView.isFullyVisible(view: View): Boolean = diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt index 11b7c146..d19933f5 100644 --- a/java/src/com/android/intentresolver/widget/ViewExtensions.kt +++ b/java/src/com/android/intentresolver/widget/ViewExtensions.kt @@ -19,21 +19,26 @@ package com.android.intentresolver.widget import android.util.Log import android.view.View import androidx.core.view.OneShotPreDrawListener -import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.suspendCancellableCoroutine internal suspend fun View.waitForPreDraw(): Unit = suspendCancellableCoroutine { continuation -> val isResumed = AtomicBoolean(false) - val callback = OneShotPreDrawListener.add( - this, - Runnable { - if (isResumed.compareAndSet(false, true)) { - continuation.resumeWith(Result.success(Unit)) - } else { - // it's not really expected but in some unknown corner-case let's not crash - Log.e("waitForPreDraw", "An attempt to resume a completed coroutine", Exception()) + val callback = + OneShotPreDrawListener.add( + this, + Runnable { + if (isResumed.compareAndSet(false, true)) { + continuation.resumeWith(Result.success(Unit)) + } else { + // it's not really expected but in some unknown corner-case let's not crash + Log.e( + "waitForPreDraw", + "An attempt to resume a completed coroutine", + Exception() + ) + } } - } - ) + ) continuation.invokeOnCancellation { callback.removeListener() } } diff --git a/lint-baseline.xml b/lint-baseline.xml new file mode 100644 index 00000000..c970b7a7 --- /dev/null +++ b/lint-baseline.xml @@ -0,0 +1,2425 @@ +<?xml version="1.0" encoding="UTF-8"?> +<issues format="6" by="lint 8.4.0-alpha08" type="baseline" client="" dependencies="true" name="" variant="all" version="8.4.0-alpha08"> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="421" + column="21"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="431" + column="25"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="517" + column="21"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="526" + column="25"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="722" + column="17"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="733" + column="21"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainThreadHandler())) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1684" + column="17"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainThreadHandler().post(() -> {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="2199" + column="13"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" context.getMainExecutor()," + errorLine2=" ~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="192" + column="25"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" }, getApplicationContext().getMainExecutor());" + errorLine2=" ~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java" + line="161" + column="44"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="297" + column="21"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="307" + column="25"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="374" + column="17"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" getMainLooper()," + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="383" + column="21"/> + </issue> + + <issue + id="NonInjectedMainThread" + message="Replace with injected `@Main Executor`." + errorLine1=" runnable -> context.getMainThreadHandler().post(runnable));" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java" + line="127" + column="37"/> + </issue> + + <issue + id="WrongCommentType" + message="This block comment looks like it was intended to be a javadoc comment" + errorLine1=" * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="821" + column="8"/> + </issue> + + <issue + id="CleanArchitectureDependencyViolation" + message="The ui layer may not depend on the data layer." + errorLine1="import com.android.intentresolver.data.model.ANDROID_APP_SCHEME" + errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/model/ActivityModel.kt" + line="23" + column="1"/> + </issue> + + <issue + id="CleanArchitectureDependencyViolation" + message="The ui layer may not depend on the data layer." + errorLine1="import com.android.intentresolver.data.model.ChooserRequest" + errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt" + line="45" + column="1"/> + </issue> + + <issue + id="CleanArchitectureDependencyViolation" + message="The ui layer may not depend on the data layer." + errorLine1="import com.android.intentresolver.data.model.ChooserRequest" + errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt" + line="25" + column="1"/> + </issue> + + <issue + id="CleanArchitectureDependencyViolation" + message="The ui layer may not depend on the data layer." + errorLine1="import com.android.intentresolver.data.repository.ChooserRequestRepository" + errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt" + line="26" + column="1"/> + </issue> + + <issue + id="CleanArchitectureDependencyViolation" + message="The ui layer may not depend on the data layer." + errorLine1="import com.android.intentresolver.data.repository.DevicePolicyResources" + errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt" + line="21" + column="1"/> + </issue> + + <issue + id="CleanArchitectureDependencyViolation" + message="The domain layer may not depend on the ui layer." + errorLine1="import com.android.intentresolver.ui.viewmodel.readAlternateIntents" + errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt" + line="40" + column="1"/> + </issue> + + <issue + id="CleanArchitectureDependencyViolation" + message="The domain layer may not depend on the ui layer." + errorLine1="import com.android.intentresolver.ui.viewmodel.readChooserActions" + errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt" + line="41" + column="1"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" (UsageStatsManager) userContext.getSystemService(Context.USAGE_STATS_SERVICE));" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/model/AbstractResolverComparator.java" + line="136" + column="53"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" .getSystemService(AppPredictionManager::class.java)" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt" + line="66" + column="14"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" (UserManager) context.getSystemService(Context.USER_SERVICE);" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="289" + column="47"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" getContext().getSystemService(LauncherApps.class).pinShortcuts(" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java" + line="226" + column="22"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" List<ShortcutManager.ShareShortcutInfo> targets = contextAsUser.getSystemService(" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java" + line="233" + column="73"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" .getSystemService(ACTIVITY_SERVICE);" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java" + line="279" + column="18"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" context.getSystemService(ActivityManager::class.java)?.launcherLargeIconDensity" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt" + line="47" + column="21"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" return getSystemService(DevicePolicyManager.class).getResources().getString(" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java" + line="165" + column="16"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" return getSystemService(DevicePolicyManager.class).getResources().getString(" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java" + line="171" + column="16"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" return getSystemService(UserManager.class);" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java" + line="402" + column="20"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" LauncherApps launcherApps = context.getSystemService(LauncherApps.class);" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java" + line="100" + column="49"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" return mContext.getSystemService(DevicePolicyManager.class).getResources().getString(" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java" + line="127" + column="29"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" return mContext.getSystemService(DevicePolicyManager.class).getResources().getString(" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java" + line="135" + column="29"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" (UserManager) mContext.getSystemService(Context.USER_SERVICE);" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java" + line="503" + column="52"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt" + line="77" + column="39"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" selectedProfileContext.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager?" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt" + line="209" + column="36"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" final ActivityManager am = (ActivityManager) ctx.getSystemService(ACTIVITY_SERVICE);" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SimpleIconFactory.java" + line="98" + column="62"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" return requireNotNull(context.getSystemService(serviceType.java))" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/data/repository/UserScopedService.kt" + line="65" + column="39"/> + </issue> + + <issue + id="NonInjectedService" + message="Use `@Inject` to get system-level service handles instead of `Context.getSystemService()`" + errorLine1=" String title = mContext.getSystemService(DevicePolicyManager.class)" + errorLine2=" ~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java" + line="83" + column="33"/> + </issue> + + <issue + id="StaticSettingsProvider" + message="`@Inject` a GlobalSettings instead" + errorLine1=" return Settings.Global.getInt(getContentResolver()," + errorLine2=" ~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarderActivity.java" + line="280" + column="32"/> + </issue> + + <issue + id="StaticSettingsProvider" + message="`@Inject` a SecureSettings instead" + errorLine1=" return Settings.Secure.getString(resolver, name)" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt" + line="32" + column="32"/> + </issue> + + <issue + id="StaticSettingsProvider" + message="`@Inject` a SecureSettings instead" + errorLine1=" return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull()" + errorLine2=" ~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt" + line="36" + column="46"/> + </issue> + + <issue + id="StaticSettingsProvider" + message="`@Inject` a SecureSettings instead" + errorLine1=" return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull()" + errorLine2=" ~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt" + line="40" + column="46"/> + </issue> + + <issue + id="StaticSettingsProvider" + message="`@Inject` a SecureSettings instead" + errorLine1=" return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull()" + errorLine2=" ~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt" + line="44" + column="46"/> + </issue> + + <issue + id="StaticSettingsProvider" + message="`@Inject` a SecureSettings instead" + errorLine1=" return Settings.Secure.getString(resolver, name)" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SecureSettings.kt" + line="25" + column="32"/> + </issue> + + <issue + id="CanvasSize" + message="Calling `Canvas.getWidth()` is usually wrong; you should be calling `getWidth()` instead" + errorLine1=" int xPos = canvas.getWidth() / 2;" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/RoundedRectImageView.java" + line="134" + column="24"/> + </issue> + + <issue + id="CanvasSize" + message="Calling `Canvas.getHeight()` is usually wrong; you should be calling `getHeight()` instead" + errorLine1=" int yPos = (int) ((canvas.getHeight() / 2.0f)" + errorLine2=" ~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/RoundedRectImageView.java" + line="135" + column="32"/> + </issue> + + <issue + id="CustomViewStyleable" + message="By convention, the declare-styleable (`ResolverDrawerLayout_LayoutParams`) for a layout parameter class (`LayoutParams`) is expected to be the surrounding class (`ResolverDrawerLayout`) plus "`_Layout`", e.g. `ResolverDrawerLayout_Layout`. (Various editor features rely on this convention.)" + errorLine1=" R.styleable.ResolverDrawerLayout_LayoutParams);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java" + line="1222" + column="21"/> + </issue> + + <issue + id="InconsistentLayout" + message="The id "edit" in layout "image_preview_image_item" is missing from the following layout configurations: layout (present in layout-h480dp)" + errorLine1=" android:id="@+id/edit"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout-h480dp/image_preview_image_item.xml" + line="58" + column="9" + message="Occurrence in layout-h480dp"/> + </issue> + + <issue + id="MissingConstraints" + message="This view is not constrained vertically: at runtime it will jump to the top unless you add a vertical constraint" + errorLine1=" <TextView" + errorLine2=" ~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_headline_row.xml" + line="27" + column="6"/> + </issue> + + <issue + id="MissingConstraints" + message="This view is not constrained horizontally: at runtime it will jump to the left unless you add a horizontal constraint" + errorLine1=" <com.android.intentresolver.widget.RoundedRectImageView" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout-h480dp/image_preview_image_item.xml" + line="24" + column="6"/> + </issue> + + <issue + id="InflateParams" + message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)" + errorLine1=" R.layout.resolver_different_item_header, null, false);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1197" + column="62"/> + </issue> + + <issue + id="InflateParams" + message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)" + errorLine1=" ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false)" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java" + line="123" + column="88"/> + </issue> + + <issue + id="InflateParams" + message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)" + errorLine1=" : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java" + line="124" + column="83"/> + </issue> + + <issue + id="InflateParams" + message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)" + errorLine1=" R.layout.resolver_different_item_header, null, false);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="853" + column="62"/> + </issue> + + <issue + id="InflateParams" + message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout's root element)" + errorLine1=" R.layout.resolver_list_per_profile, null, false)," + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java" + line="80" + column="69"/> + </issue> + + <issue + id="ManifestOrder" + message="`<uses-sdk>` tag appears after `<application>` tag" + errorLine1=" <uses-sdk android:minSdkVersion="VanillaIceCream" android:targetSdkVersion="16"/>" + errorLine2=" ~~~~~~~~"> + <location + file="./out/soong/.intermediates/packages/modules/IntentResolver/IntentResolver-core/android_common/e18b8e8d84cb9f664aa09a397b08c165/manifest_fixer/AndroidManifest.xml" + line="22" + column="6"/> + </issue> + + <issue + id="MissingInflatedId" + message="`@layout/chooser_dialog` does not contain a declaration with id `title`" + errorLine1=" TextView title = v.findViewById(com.android.internal.R.id.title);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java" + line="133" + column="41"/> + </issue> + + <issue + id="MissingInflatedId" + message="`@layout/chooser_dialog` does not contain a declaration with id `icon`" + errorLine1=" ImageView icon = v.findViewById(com.android.internal.R.id.icon);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java" + line="134" + column="41"/> + </issue> + + <issue + id="MissingInflatedId" + message="`@layout/chooser_dialog` does not contain a declaration with id `listContainer`" + errorLine1=" RecyclerView rv = v.findViewById(com.android.internal.R.id.listContainer);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java" + line="135" + column="42"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="437" + column="42"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="559" + column="54"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="761" + column="54"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="766" + column="46"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="771" + column="68"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0;" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="900" + column="50"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" .getActiveListAdapter().getFilteredItem()))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="909" + column="38"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1133" + column="54"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getItem(i);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1136" + column="66"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1155" + column="46"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1206" + column="50"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1217" + column="54"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1483" + column="42"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults(" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1623" + column="50"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1699" + column="50"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1724" + column="62"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1731" + column="58"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1801" + column="50"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getActiveListAdapter();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1838" + column="58"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getCurrentUserHandle());" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1886" + column="50"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mChooserMultiProfilePagerAdapter.getCurrentUserHandle());" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1914" + column="50"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" .getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="1981" + column="34"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals(" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="2041" + column="46"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" if (listProfileUserHandle.equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="2288" + column="75"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserActivity.java" + line="2351" + column="46"/> + </issue> + + <issue + id="VisibleForTests" + message="This class should only be accessed from tests or within private scope" + errorLine1=" final ViewHolder vh = (ViewHolder) v.getTag();" + errorLine2=" ~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java" + line="414" + column="23"/> + </issue> + + <issue + id="VisibleForTests" + message="This class should only be accessed from tests or within private scope" + errorLine1=" final ViewHolder vh = (ViewHolder) v.getTag();" + errorLine2=" ~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java" + line="414" + column="40"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" vh.text.setLines(2);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java" + line="415" + column="20"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" vh.text.setHorizontallyScrolling(false);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java" + line="416" + column="20"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" vh.text2.setVisibility(View.GONE);" + errorLine2=" ~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java" + line="417" + column="20"/> + </issue> + + <issue + id="VisibleForTests" + message="This class should only be accessed from tests or within private scope" + errorLine1=" private void resetViewHolder(ViewHolder holder) {" + errorLine2=" ~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="432" + column="34"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.reset();" + errorLine2=" ~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="433" + column="16"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.itemView.setBackground(holder.defaultItemViewBackground);" + errorLine2=" ~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="434" + column="16"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.itemView.setBackground(holder.defaultItemViewBackground);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="434" + column="46"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" ((BadgeTextView) holder.text).setBadgeDrawable(null);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="437" + column="37"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.text.setBackground(null);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="439" + column="16"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.text.setPaddingRelative(0, 0, 0, 0);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="440" + column="16"/> + </issue> + + <issue + id="VisibleForTests" + message="This class should only be accessed from tests or within private scope" + errorLine1=" private void updateContentDescription(ViewHolder holder, String description) {" + errorLine2=" ~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="443" + column="43"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.itemView.setContentDescription(description);" + errorLine2=" ~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="444" + column="16"/> + </issue> + + <issue + id="VisibleForTests" + message="This class should only be accessed from tests or within private scope" + errorLine1=" private void bindPlaceholder(ViewHolder holder) {" + errorLine2=" ~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="447" + column="34"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.itemView.setBackground(null);" + errorLine2=" ~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="448" + column="16"/> + </issue> + + <issue + id="VisibleForTests" + message="This class should only be accessed from tests or within private scope" + errorLine1=" private void bindGroupIndicator(ViewHolder holder, Drawable indicator) {" + errorLine2=" ~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="451" + column="37"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" ((BadgeTextView) holder.text).setBadgeDrawable(indicator);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="453" + column="37"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="455" + column="20"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.text.setBackground(indicator);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="456" + column="20"/> + </issue> + + <issue + id="VisibleForTests" + message="This class should only be accessed from tests or within private scope" + errorLine1=" private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) {" + errorLine2=" ~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="460" + column="38"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="461" + column="16"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" holder.text.setBackground(indicator);" + errorLine2=" ~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="462" + column="16"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java" + line="115" + column="13"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" getActiveListAdapter().notifyDataSetChanged();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java" + line="135" + column="9"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" getPageAdapterForIndex(i).setFooterHeight(height);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java" + line="150" + column="13"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" ChooserGridAdapter adapter = getPageAdapterForIndex(i);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java" + line="157" + column="42"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" /* instance_id = 3 */ mInstanceId.getId()," + errorLine2=" ~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/logging/EventLogImpl.java" + line="96" + column="51"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" /* instance_id = 3 */ mInstanceId.getId()," + errorLine2=" ~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/logging/EventLogImpl.java" + line="118" + column="51"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" /* instance_id = 3 */ mInstanceId.getId()," + errorLine2=" ~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/logging/EventLogImpl.java" + line="142" + column="51"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" /* instance_id = 3 */ mInstanceId.getId()," + errorLine2=" ~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/logging/EventLogImpl.java" + line="200" + column="51"/> + </issue> + + <issue + id="VisibleForTests" + message="This class should only be accessed from tests or within private scope" + errorLine1=" private final ResolverListAdapter.ViewHolder mWrappedViewHolder;" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ItemViewHolder.java" + line="36" + column="19"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mWrappedViewHolder = new ResolverListAdapter.ViewHolder(itemView);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ItemViewHolder.java" + line="46" + column="30"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="313" + column="35"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" && mMultiProfilePagerAdapter.getActiveListAdapter() != null) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="323" + column="46"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="324" + column="39"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="415" + column="62"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="540" + column="35"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="560" + column="76"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="572" + column="52"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="582" + column="55"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="596" + column="47"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="627" + column="46"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" final int N = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="713" + column="57"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null;" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="720" + column="51"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="729" + column="63"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" set[N] = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="737" + column="56"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="739" + column="77"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="761" + column="51"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" .mResolverListController.setLastChosen(intent, filter, bestMatch);" + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="762" + column="58"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="868" + column="43"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1143" + column="47"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter().getItem(i);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1146" + column="59"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0;" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1172" + column="43"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" .getActiveListAdapter().getFilteredItem()))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1181" + column="42"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" if (!mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getUser())) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1217" + column="40"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" ri = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1233" + column="44"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle());" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1326" + column="59"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" if (mMultiProfilePagerAdapter.getActiveListAdapter() == null) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1334" + column="39"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1399" + column="25"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" new ResolverListAdapter.ViewHolder(icon).bindIcon(otherProfileResolveInfo);" + errorLine2=" ~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1399" + column="66"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1472" + column="47"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1534" + column="47"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" if (mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1539" + column="39"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" final TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1544" + column="61"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1687" + column="75"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" int filteredPosition = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1754" + column="58"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" if (mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1795" + column="43"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1828" + column="56"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" final TargetInfo ti = ra.mMultiProfilePagerAdapter.getActiveListAdapter()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverActivity.java" + line="1896" + column="68"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" return mResolverListController.getScore(target);" + errorLine2=" ~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java" + line="212" + column="40"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mResolverListController.addResolveListDedupe(currentResolveList," + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java" + line="329" + column="37"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mResolverListController.filterIneligibleActivities(currentResolveList, true);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java" + line="362" + column="41"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" return mResolverListController.filterLowPriority(" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java" + line="384" + column="40"/> + </issue> + + <issue + id="VisibleForTests" + message="This method should only be accessed from tests or within private scope" + errorLine1=" mLastChosen = mResolverListController.getLastChosen();" + errorLine2=" ~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ResolverListAdapter.java" + line="410" + column="55"/> + </issue> + + <issue + id="SupportAnnotationUsage" + message="This annotation does not apply for type java.lang.Object; expected int" + errorLine1=" @ContentPreviewType" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt" + line="230" + column="5"/> + </issue> + + <issue + id="ExpiredTargetSdkVersion" + message="Google Play requires that apps target API level 33 or higher." + errorLine1=" <uses-sdk android:minSdkVersion="VanillaIceCream" android:targetSdkVersion="16"/>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="./out/soong/.intermediates/packages/modules/IntentResolver/IntentResolver-core/android_common/e18b8e8d84cb9f664aa09a397b08c165/manifest_fixer/AndroidManifest.xml" + line="22" + column="55"/> + </issue> + + <issue + id="BindServiceOnMainThread" + message="This method should be annotated with `@WorkerThread` because it calls unbindService" + errorLine1=" mContext.unbindService(mConnection);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java" + line="308" + column="13"/> + </issue> + + <issue + id="BindServiceOnMainThread" + message="This method should be annotated with `@WorkerThread` because it calls bindServiceAsUser" + errorLine1=" context.bindServiceAsUser(intent, mConnection, Context.BIND_AUTO_CREATE, UserHandle.SYSTEM);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java" + line="333" + column="9"/> + </issue> + + <issue + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java" + line="139" + column="17"/> + </issue> + + <issue + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged();" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java" + line="145" + column="17"/> + </issue> + + <issue + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ScrollableActionRow.kt" + line="94" + column="13"/> + </issue> + + <issue + id="NotifyDataSetChanged" + message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort." + errorLine1=" notifyDataSetChanged()" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt" + line="316" + column="13"/> + </issue> + + <issue + id="RegisterReceiverViaContext" + message="Register `BroadcastReceiver` using `BroadcastDispatcher` instead of `Context`" + errorLine1=" context.registerReceiverAsUser(" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt" + line="63" + column="17"/> + </issue> + + <issue + id="RegisterReceiverViaContext" + message="Register `BroadcastReceiver` using `BroadcastDispatcher` instead of `Context`" + errorLine1=" context.registerReceiverAsUser(" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/WorkProfileAvailabilityManager.java" + line="74" + column="17"/> + </issue> + + <issue + id="SharedFlowCreation" + message="`MutableSharedFlow()` creates a new shared flow, which has poor performance characteristics" + errorLine1=" MutableSharedFlow<FileInfo>(replay = records.size).apply {" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt" + line="91" + column="9"/> + </issue> + + <issue + id="SharedFlowCreation" + message="`MutableSharedFlow()` creates a new shared flow, which has poor performance characteristics" + errorLine1=" val reportFlow = MutableSharedFlow<Any>(replay = 2)" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt" + line="660" + column="30"/> + </issue> + + <issue + id="SharedFlowCreation" + message="`MutableSharedFlow()` creates a new shared flow, which has poor performance characteristics" + errorLine1=" MutableSharedFlow<Array<DisplayResolveInfo>?>(" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt" + line="82" + column="9"/> + </issue> + + <issue + id="SharedFlowCreation" + message="`MutableSharedFlow()` creates a new shared flow, which has poor performance characteristics" + errorLine1=" MutableSharedFlow<ShortcutData?>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt" + line="87" + column="9"/> + </issue> + + <issue + id="SlowUserIdQuery" + message="Use `UserTracker.getUserId()` instead of `ActivityManager.getCurrentUser()`" + errorLine1=" userHandle == UserHandle.of(ActivityManager.getCurrentUser())," + errorLine2=" ~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt" + line="104" + column="53"/> + </issue> + + <issue + id="SlowUserInfoQuery" + message="Use `UserTracker.getUserInfo()` instead of `UserManager.getUserInfo()`" + errorLine1=" val originUserInfo = userManager.getUserInfo(contentUserHint)" + errorLine2=" ~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/IntentForwarding.kt" + line="51" + column="46"/> + </issue> + + <issue + id="SlowUserInfoQuery" + message="Use `UserTracker.getUserInfo()` instead of `UserManager.getUserInfo()`" + errorLine1=" withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) }" + errorLine2=" ~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/data/repository/UserRepository.kt" + line="267" + column="61"/> + </issue> + + <issue + id="SoftwareBitmap" + message="Replace software bitmap with `Config.HARDWARE`" + errorLine1=" mBitmap = Bitmap.createBitmap(mMaxSize, mMaxSize, Bitmap.Config.ALPHA_8);" + errorLine2=" ~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SimpleIconFactory.java" + line="172" + column="73"/> + </issue> + + <issue + id="SoftwareBitmap" + message="Replace software bitmap with `Config.HARDWARE`" + errorLine1=" bitmap.getHeight(), Bitmap.Config.ARGB_8888);" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SimpleIconFactory.java" + line="297" + column="51"/> + </issue> + + <issue + id="SoftwareBitmap" + message="Replace software bitmap with `Config.HARDWARE`" + errorLine1=" Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/SimpleIconFactory.java" + line="343" + column="71"/> + </issue> + + <issue + id="ObsoleteLayoutParam" + message="Invalid layout param in a `LinearLayout`: `layout_alignParentTop`" + errorLine1=" android:layout_alignParentTop="true"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_grid_scrollable_preview.xml" + line="99" + column="17"/> + </issue> + + <issue + id="ObsoleteLayoutParam" + message="Invalid layout param in a `LinearLayout`: `layout_centerHorizontal`" + errorLine1=" android:layout_centerHorizontal="true"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_grid_scrollable_preview.xml" + line="100" + column="17"/> + </issue> + + <issue + id="StaticFieldLeak" + message="This field leaks a context object" + errorLine1=" protected final Context mContext;" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java" + line="29" + column="5"/> + </issue> + + <issue + id="StaticFieldLeak" + message="This `AsyncTask` class should be static or leaks might occur (anonymous android.os.AsyncTask)" + errorLine1=" new AsyncTask<Void, Void, List<DisplayResolveInfo>>() {" + errorLine2=" ^"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/ChooserListAdapter.java" + line="488" + column="9"/> + </issue> + + <issue + id="StaticFieldLeak" + message="This field leaks a context object" + errorLine1=" private final Context mContext;" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/icons/LoadLabelTask.java" + line="32" + column="5"/> + </issue> + + <issue + id="UseCompoundDrawables" + message="This tag and its children can be replaced by one `<TextView/>` and a compound drawable" + errorLine1=" <LinearLayout" + errorLine2=" ~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_dialog.xml" + line="29" + column="6"/> + </issue> + + <issue + id="Overdraw" + message="Possible overdraw: Root element paints background `?androidprv:attr/materialColorSurfaceContainer` with a theme that also paints a background (inferred theme is `@android:style/Theme.Holo`)" + errorLine1=" android:background="?androidprv:attr/materialColorSurfaceContainer">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_file.xml" + line="27" + column="5"/> + </issue> + + <issue + id="Overdraw" + message="Possible overdraw: Root element paints background `?androidprv:attr/materialColorSurfaceContainer` with a theme that also paints a background (inferred theme is `@android:style/Theme.Holo`)" + errorLine1=" android:background="?androidprv:attr/materialColorSurfaceContainer">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_files_text.xml" + line="26" + column="5"/> + </issue> + + <issue + id="Overdraw" + message="Possible overdraw: Root element paints background `?androidprv:attr/materialColorSurfaceContainer` with a theme that also paints a background (inferred theme is `@android:style/Theme.Holo`)" + errorLine1=" android:background="?androidprv:attr/materialColorSurfaceContainer">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_image.xml" + line="27" + column="5"/> + </issue> + + <issue + id="Overdraw" + message="Possible overdraw: Root element paints background `?androidprv:attr/materialColorSurfaceContainer` with a theme that also paints a background (inferred theme is `@android:style/Theme.Holo`)" + errorLine1=" android:background="?androidprv:attr/materialColorSurfaceContainer">" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_text.xml" + line="28" + column="5"/> + </issue> + + <issue + id="RedundantNamespace" + message="This namespace declaration is redundant" + errorLine1=" <vector xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/drawable/chooser_direct_share_icon_placeholder.xml" + line="20" + column="17"/> + </issue> + + <issue + id="RedundantNamespace" + message="This namespace declaration is redundant" + errorLine1=" xmlns:aapt="http://schemas.android.com/aapt"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/drawable/chooser_direct_share_icon_placeholder.xml" + line="21" + column="17"/> + </issue> + + <issue + id="RedundantNamespace" + message="This namespace declaration is redundant" + errorLine1=" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/resolve_list_item.xml" + line="40" + column="19"/> + </issue> + + <issue + id="RedundantNamespace" + message="This namespace declaration is redundant" + errorLine1=" xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/resolver_list_per_profile.xml" + line="23" + column="9"/> + </issue> + + <issue + id="UnusedNamespace" + message="Unused namespace declaration xmlns:android; already declared on the root element" + errorLine1=" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/resolve_list_item.xml" + line="40" + column="19"/> + </issue> + + <issue + id="UnusedNamespace" + message="Unused namespace declaration xmlns:android; already declared on the root element" + errorLine1=" xmlns:android="http://schemas.android.com/apk/res/android"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/resolver_list_per_profile.xml" + line="23" + column="9"/> + </issue> + + <issue + id="TypographyEllipsis" + message="Replace "..." with ellipsis character (…, &#8230;) ?" + errorLine1=" <string name="whichApplication" msgid="2309561338625872614">"... በመጠቀም ድርጊቱን አጠናቅ"</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/values-am/strings.xml" + line="19" + column="65"/> + </issue> + + <issue + id="TypographyEllipsis" + message="Replace "..." with ellipsis character (…, &#8230;) ?" + errorLine1=" <string name="whichApplication" msgid="2309561338625872614">"Wykonaj czynność przez..."</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/values-pl/strings.xml" + line="19" + column="65"/> + </issue> + + <issue + id="TypographyEllipsis" + message="Replace "..." with ellipsis character (…, &#8230;) ?" + errorLine1=" <string name="whichViewApplication" msgid="7660051361612888119">"...ဖြင့် ဖွင့်မည်"</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/values-my/strings.xml" + line="22" + column="69"/> + </issue> + + <issue + id="TypographyEllipsis" + message="Replace "..." with ellipsis character (…, &#8230;) ?" + errorLine1=" <string name="whichEditApplication" msgid="5097563012157950614">"...နှင့် တည်းဖြတ်ရန်"</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/values-my/strings.xml" + line="30" + column="69"/> + </issue> + + <issue + id="TypographyEllipsis" + message="Replace "..." with ellipsis character (…, &#8230;) ?" + errorLine1=" <string name="whichSendToApplication" msgid="2724450540348806267">"Sūtīšana, izmantojot..."</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/values-lv/strings.xml" + line="36" + column="71"/> + </issue> + + <issue + id="ClickableViewAccessibility" + message="Custom view `ResolverDrawerLayout` overrides `onTouchEvent` but not `performClick`" + errorLine1=" public boolean onTouchEvent(MotionEvent ev) {" + errorLine2=" ~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java" + line="403" + column="20"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView android:id="@android:id/icon"" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_dialog.xml" + line="37" + column="10"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView android:id="@android:id/icon"" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_dialog_item.xml" + line="30" + column="6"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView android:id="@android:id/icon"" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_grid_item.xml" + line="32" + column="6"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_file.xml" + line="47" + column="10"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_grid_preview_text.xml" + line="112" + column="8"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/image_preview_image_item.xml" + line="43" + column="10"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout-h480dp/image_preview_image_item.xml" + line="46" + column="10"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout-h480dp/image_preview_image_item.xml" + line="68" + column="10"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/miniresolver.xml" + line="39" + column="10"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView android:id="@android:id/icon"" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/resolve_grid_item.xml" + line="32" + column="6"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView android:id="@android:id/icon"" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/resolve_list_item.xml" + line="30" + column="6"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/resolver_list_with_default.xml" + line="44" + column="14"/> + </issue> + + <issue + id="ContentDescription" + message="Missing `contentDescription` attribute on image" + errorLine1=" <ImageView" + errorLine2=" ~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/resolver_list_with_default.xml" + line="79" + column="18"/> + </issue> + + <issue + id="HardcodedText" + message="Hardcoded string "App name", should use `@string` resource" + errorLine1=" android:text="App name"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/chooser_dialog.xml" + line="46" + column="19"/> + </issue> + + <issue + id="RelativeOverlap" + message="`@androidprv:id/button_open` can overlap `@androidprv:id/use_same_profile_browser` if @string/activity_resolver_use_once, @string/whichViewApplicationLabel grow due to localized text expansion" + errorLine1=" <Button" + errorLine2=" ~~~~~~"> + <location + file="packages/modules/IntentResolver/java/res/layout/miniresolver.xml" + line="100" + column="14"/> + </issue> + +</issues> diff --git a/tests/activity/Android.bp b/tests/activity/Android.bp index f69caf0e..b718a430 100644 --- a/tests/activity/Android.bp +++ b/tests/activity/Android.bp @@ -15,6 +15,7 @@ // package { + default_team: "trendy_team_capture_and_share", default_applicable_licenses: ["Android-Apache-2.0"], } @@ -53,6 +54,7 @@ android_test { "junit", "kotlinx_coroutines_test", "mockito-target-minus-junit4", + "mockito-kotlin-nodeps", "testables", "truth", "truth-java8-extension", diff --git a/tests/activity/AndroidManifest.xml b/tests/activity/AndroidManifest.xml index be05e99e..00dbd78d 100644 --- a/tests/activity/AndroidManifest.xml +++ b/tests/activity/AndroidManifest.xml @@ -26,8 +26,8 @@ <uses-library android:name="android.test.runner" /> <activity android:name="com.android.intentresolver.ChooserWrapperActivity" /> <activity android:name="com.android.intentresolver.ResolverWrapperActivity" /> - <activity android:name="com.android.intentresolver.v2.ChooserWrapperActivity" /> - <activity android:name="com.android.intentresolver.v2.ResolverWrapperActivity" /> + <activity android:name="com.android.intentresolver.ChooserWrapperActivity" /> + <activity android:name="com.android.intentresolver.ResolverWrapperActivity" /> <provider android:authorities="com.android.intentresolver.tests" android:name="com.android.intentresolver.TestContentProvider" diff --git a/tests/activity/AndroidTest.xml b/tests/activity/AndroidTest.xml index 6c9d4953..04e4e69f 100644 --- a/tests/activity/AndroidTest.xml +++ b/tests/activity/AndroidTest.xml @@ -27,6 +27,7 @@ <test class="com.android.tradefed.testtype.AndroidJUnitTest" > <option name="package" value="com.android.intentresolver.tests" /> <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" /> + <option name="instrumentation-arg" key="thisisignored" value="thisisignored --no-window-animation" /> <option name="hidden-api-checks" value="false"/> </test> </configuration> diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java index 3ee80c14..507ce3d7 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -21,7 +21,6 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import android.content.pm.PackageManager; import android.content.res.Resources; import android.database.Cursor; import android.os.UserHandle; @@ -31,11 +30,11 @@ import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.shortcuts.ShortcutLoader; +import kotlin.jvm.functions.Function2; + 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 @@ -50,75 +49,35 @@ public class ChooserActivityOverrideData { } return sInstance; } - - @SuppressWarnings("Since15") - public Function<PackageManager, PackageManager> createPackageManager; public Function<TargetInfo, Boolean> onSafelyStartInternalCallback; public Function<TargetInfo, Boolean> onSafelyStartCallback; public Function2<UserHandle, Consumer<ShortcutLoader.Result>, ShortcutLoader> shortcutLoaderFactory = (userHandle, callback) -> null; - public ChooserActivity.ChooserListController resolverListController; - public ChooserActivity.ChooserListController workResolverListController; + public ChooserListController resolverListController; + public ChooserListController workResolverListController; public Boolean isVoiceInteraction; public Cursor resolverCursor; public boolean resolverForceException; public ImageLoader imageLoader; - public int alternateProfileSetting; public Resources resources; - public AnnotatedUserHandles annotatedUserHandles; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; public Integer myUserId; - public WorkProfileAvailabilityManager mWorkProfileAvailability; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; - public PackageManager packageManager; public void reset() { onSafelyStartInternalCallback = null; isVoiceInteraction = null; - createPackageManager = null; imageLoader = null; resolverCursor = null; resolverForceException = false; - resolverListController = mock(ChooserActivity.ChooserListController.class); - workResolverListController = mock(ChooserActivity.ChooserListController.class); - alternateProfileSetting = 0; + resolverListController = mock(ChooserListController.class); + workResolverListController = mock(ChooserListController.class); resources = null; - annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) - .setPersonalProfileUserHandle(UserHandle.SYSTEM) - .build(); hasCrossProfileIntents = true; isQuietModeEnabled = false; myUserId = null; - packageManager = null; - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java index b8113422..a16e201b 100644 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2; +package com.android.intentresolver; import static android.app.Activity.RESULT_OK; @@ -88,7 +88,6 @@ import android.graphics.drawable.Icon; import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; -import android.platform.test.annotations.RequiresFlagsEnabled; import android.platform.test.flag.junit.CheckFlagsRule; import android.platform.test.flag.junit.DeviceFlagsValueProvider; import android.provider.DeviceConfig; @@ -117,27 +116,29 @@ import androidx.test.espresso.matcher.ViewMatchers; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.AnnotatedUserHandles; -import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.Flags; -import com.android.intentresolver.IChooserWrapper; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolvedComponentInfo; -import com.android.intentresolver.ResolverDataProvider; -import com.android.intentresolver.TestContentProvider; -import com.android.intentresolver.TestPreviewImageLoader; import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.contentpreview.FakeThumbnailLoader; import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.contentpreview.ImageLoaderModule; +import com.android.intentresolver.contentpreview.PreviewCacheSize; +import com.android.intentresolver.contentpreview.PreviewMaxConcurrency; +import com.android.intentresolver.contentpreview.ThumbnailLoader; +import com.android.intentresolver.data.repository.FakeUserRepository; +import com.android.intentresolver.data.repository.UserRepository; +import com.android.intentresolver.data.repository.UserRepositoryModule; +import com.android.intentresolver.ext.RecyclerViewExt; +import com.android.intentresolver.inject.ApplicationUser; import com.android.intentresolver.inject.PackageManagerModule; +import com.android.intentresolver.inject.ProfileParent; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; +import com.android.intentresolver.platform.AppPredictionAvailable; +import com.android.intentresolver.platform.AppPredictionModule; +import com.android.intentresolver.platform.ImageEditor; +import com.android.intentresolver.platform.ImageEditorModule; +import com.android.intentresolver.shared.model.User; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.intentresolver.v2.platform.AppPredictionAvailable; -import com.android.intentresolver.v2.platform.AppPredictionModule; -import com.android.intentresolver.v2.platform.ImageEditor; -import com.android.intentresolver.v2.platform.ImageEditorModule; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import dagger.hilt.android.qualifiers.ApplicationContext; import dagger.hilt.android.testing.BindValue; @@ -160,7 +161,6 @@ import org.mockito.Mockito; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -183,9 +183,11 @@ import javax.inject.Inject; @UninstallModules({ AppPredictionModule.class, ImageEditorModule.class, - PackageManagerModule.class + PackageManagerModule.class, + ImageLoaderModule.class, + UserRepositoryModule.class, }) -public class UnbundledChooserActivityTest { +public class ChooserActivityTest { private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { return (FakeEventLog) activity.mEventLog; @@ -193,9 +195,20 @@ public class UnbundledChooserActivityTest { private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry .getInstrumentation().getTargetContext().getUser(); + + private static final User PERSONAL_USER = + new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL); + private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); + + private static final User WORK_USER = + new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK); + private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + private static final User CLONE_USER = + new User(CLONE_PROFILE_USER_HANDLE.getIdentifier(), User.Role.CLONE); + @Parameters(name = "appPrediction={0}") public static Iterable<?> parameters() { return Arrays.asList( @@ -239,6 +252,36 @@ public class UnbundledChooserActivityTest { @BindValue PackageManager mPackageManager; + /** "launchedAs" */ + @BindValue + @ApplicationUser + UserHandle mApplicationUser = PERSONAL_USER_HANDLE; + + @BindValue + @ProfileParent + UserHandle mProfileParent = PERSONAL_USER_HANDLE; + + private final FakeUserRepository mFakeUserRepo = new FakeUserRepository(List.of(PERSONAL_USER)); + + @BindValue + final UserRepository mUserRepository = mFakeUserRepo; + + private final FakeImageLoader mFakeImageLoader = new FakeImageLoader(); + + @BindValue + final ImageLoader mImageLoader = mFakeImageLoader; + + @BindValue + @PreviewCacheSize + int mPreviewCacheSize = 16; + + @BindValue + @PreviewMaxConcurrency + int mPreviewMaxConcurrency = 4; + + @BindValue + ThumbnailLoader mThumbnailLoader = new FakeThumbnailLoader(); + @Before public void setUp() { // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the @@ -257,9 +300,12 @@ public class UnbundledChooserActivityTest { // values to the dependency graph at activity launch time. This allows replacing // arbitrary bindings per-test case if needed. mPackageManager = mContext.getPackageManager(); + + // TODO: inject image loader in the prod code and remove this override + ChooserActivityOverrideData.getInstance().imageLoader = mFakeImageLoader; } - public UnbundledChooserActivityTest(boolean appPredictionAvailable) { + public ChooserActivityTest(boolean appPredictionAvailable) { mAppPredictionAvailable = appPredictionAvailable; } @@ -434,14 +480,13 @@ public class UnbundledChooserActivityTest { } @Test - public void visiblePreviewTitleAndThumbnail() throws InterruptedException { + public void visiblePreviewTitleAndThumbnail() { String previewTitle = "My Content Preview Title"; Uri uri = Uri.parse( "android.resource://com.android.frameworks.coretests/" + com.android.intentresolver.tests.R.drawable.test320x240); Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -707,8 +752,7 @@ public class UnbundledChooserActivityTest { public void testFilePlusTextSharing_ExcludeText() { Uri uri = createTestContentProviderUri(null, "image/png"); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( @@ -749,8 +793,7 @@ public class UnbundledChooserActivityTest { public void testFilePlusTextSharing_RemoveAndAddBackText() { Uri uri = createTestContentProviderUri("application/pdf", "image/png"); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); final String text = "https://google.com/search?q=google"; sendIntent.putExtra(Intent.EXTRA_TEXT, text); @@ -797,8 +840,7 @@ public class UnbundledChooserActivityTest { public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); Intent alternativeIntent = createSendTextIntent(); @@ -841,8 +883,6 @@ public class UnbundledChooserActivityTest { public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( @@ -937,8 +977,7 @@ public class UnbundledChooserActivityTest { Uri uri = createTestContentProviderUri("image/png", null); Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -962,8 +1001,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); + mFakeImageLoader.setBitmap(uri, createWideBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -976,6 +1014,7 @@ public class UnbundledChooserActivityTest { throw exception; } RecyclerView recyclerView = (RecyclerView) view; + RecyclerViewExt.endAnimations(recyclerView); assertThat("recyclerView adapter item count", recyclerView.getAdapter().getItemCount(), is(1)); assertThat("recyclerView child view count", @@ -1000,8 +1039,6 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1019,8 +1056,7 @@ public class UnbundledChooserActivityTest { ArrayList<Uri> uris = new ArrayList<>(1); uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1046,8 +1082,7 @@ public class UnbundledChooserActivityTest { } uris.add(imageUri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(imageUri, createBitmap()); + mFakeImageLoader.setBitmap(imageUri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -1079,8 +1114,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1114,12 +1148,9 @@ public class UnbundledChooserActivityTest { uris.add(docUri); Intent sendIntent = createSendUriIntentWithPreview(uris); - Map<Uri, Bitmap> bitmaps = new HashMap<>(); - bitmaps.put(imgOneUri, createWideBitmap(Color.RED)); - bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN)); - bitmaps.put(docUri, createWideBitmap(Color.BLUE)); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(bitmaps); + mFakeImageLoader.setBitmap(imgOneUri, createWideBitmap(Color.RED)); + mFakeImageLoader.setBitmap(imgTwoUri, createWideBitmap(Color.GREEN)); + mFakeImageLoader.setBitmap(docUri, createWideBitmap(Color.BLUE)); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); setupResolverControllers(resolvedComponentInfos); @@ -1137,6 +1168,7 @@ public class UnbundledChooserActivityTest { throw exception; } RecyclerView recyclerView = (RecyclerView) view; + RecyclerViewExt.endAnimations(recyclerView); assertThat(recyclerView.getChildCount()).isAtLeast(1); // the first view is a preview View imageView = recyclerView.getChildAt(0).findViewById(R.id.image); @@ -1167,8 +1199,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1197,8 +1228,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1234,8 +1264,7 @@ public class UnbundledChooserActivityTest { Intent sendIntent = createSendUriIntentWithPreview(uris); sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -1275,8 +1304,10 @@ public class UnbundledChooserActivityTest { public void testOnCreateLoggingFromWorkProfile() { Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().alternateProfileSetting = - MetricsEvent.MANAGED_PROFILE; + + // Launch as work user. + mFakeUserRepo.addUser(WORK_USER, true); + mApplicationUser = WORK_PROFILE_USER_HANDLE; ChooserWrapperActivity activity = mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); @@ -1331,8 +1362,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); + mFakeImageLoader.setBitmap(uri, createBitmap()); List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); @@ -2174,7 +2204,7 @@ public class UnbundledChooserActivityTest { createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_USER, false); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -2213,7 +2243,6 @@ public class UnbundledChooserActivityTest { } @Test - @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) public void testWorkTab_previewIsScrollable() { markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List<ResolvedComponentInfo> personalResolvedComponentInfos = @@ -2228,8 +2257,7 @@ public class UnbundledChooserActivityTest { uris.add(uri); Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); + mFakeImageLoader.setBitmap(uri, createWideBitmap()); mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); waitForIdle(); @@ -2257,7 +2285,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(0); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_USER, false); ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -2281,7 +2309,7 @@ public class UnbundledChooserActivityTest { List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(0); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_USER, false); Intent sendIntent = createSendTextIntent(); sendIntent.setType(TEST_MIME_TYPE); @@ -3017,18 +3045,12 @@ public class UnbundledChooserActivityTest { } private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { - AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); - handles - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) - .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); if (workAvailable) { - handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); + mFakeUserRepo.addUser(WORK_USER, /* available= */ true); } if (cloneAvailable) { - handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + mFakeUserRepo.addUser(CLONE_USER, /* available= */ true); } - ChooserWrapperActivity.sOverrides.annotatedUserHandles = handles.build(); } private void setupResolverControllers( @@ -3048,8 +3070,8 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + eq(PERSONAL_USER_HANDLE))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); when( ChooserActivityOverrideData .getInstance() @@ -3059,19 +3081,8 @@ public class UnbundledChooserActivityTest { Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when( - ChooserActivityOverrideData - .getInstance() - .workResolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.of(10)))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + eq(WORK_PROFILE_USER_HANDLE))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); } private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { @@ -3134,8 +3145,4 @@ public class UnbundledChooserActivityTest { }; return shortcutLoaders; } - - private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) { - return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap)); - } } diff --git a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java index da879f74..022ae2e1 100644 --- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityWorkProfileTest.java @@ -27,14 +27,14 @@ 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.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; +import static com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab.WORK; 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; @@ -44,11 +44,22 @@ import android.companion.DeviceFilter; import android.content.Intent; import android.os.UserHandle; -import androidx.test.InstrumentationRegistry; import androidx.test.espresso.NoMatchingViewException; +import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; -import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; +import com.android.intentresolver.ChooserActivityWorkProfileTest.TestCase.Tab; +import com.android.intentresolver.data.repository.FakeUserRepository; +import com.android.intentresolver.data.repository.UserRepository; +import com.android.intentresolver.data.repository.UserRepositoryModule; +import com.android.intentresolver.inject.ApplicationUser; +import com.android.intentresolver.inject.ProfileParent; +import com.android.intentresolver.shared.model.User; + +import dagger.hilt.android.testing.BindValue; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; +import dagger.hilt.android.testing.UninstallModules; import junit.framework.AssertionFailedError; @@ -64,13 +75,11 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - @DeviceFilter.MediumType @RunWith(Parameterized.class) @HiltAndroidTest -public class UnbundledChooserActivityWorkProfileTest { +@UninstallModules(UserRepositoryModule.class) +public class ChooserActivityWorkProfileTest { private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry .getInstrumentation().getTargetContext().getUser(); @@ -83,10 +92,31 @@ public class UnbundledChooserActivityWorkProfileTest { public ActivityTestRule<ChooserWrapperActivity> mActivityRule = new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); + + @BindValue + @ApplicationUser + public final UserHandle mApplicationUser; + + @BindValue + @ProfileParent + public final UserHandle mProfileParent; + + /** For setup of test state, a mutable reference of mUserRepository */ + private final FakeUserRepository mFakeUserRepo = new FakeUserRepository( + List.of(new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL))); + + @BindValue + public final UserRepository mUserRepository; + private final TestCase mTestCase; - public UnbundledChooserActivityWorkProfileTest(TestCase testCase) { + public ChooserActivityWorkProfileTest(TestCase testCase) { mTestCase = testCase; + mApplicationUser = mTestCase.getMyUserHandle(); + mProfileParent = PERSONAL_USER_HANDLE; + mUserRepository = new FakeUserRepository(List.of( + new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL), + new User(WORK_USER_HANDLE.getIdentifier(), User.Role.WORK))); } @Before @@ -267,12 +297,6 @@ public class UnbundledChooserActivityWorkProfileTest { } private void setUpPersonalAndWorkComponentInfos() { - ChooserWrapperActivity.sOverrides.annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(mTestCase.getMyUserHandle()) - .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE) - .setWorkProfileUserHandle(WORK_USER_HANDLE) - .build(); int workProfileTargets = 4; List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, diff --git a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java index 37bbc6ce..4b71aa29 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -16,14 +16,13 @@ package com.android.intentresolver; +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; import android.database.Cursor; @@ -31,17 +30,12 @@ import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.lifecycle.ViewModelProvider; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import java.util.List; import java.util.function.Consumer; @@ -55,7 +49,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW private UsageStatsManager mUsm; @Override - public ChooserListAdapter createChooserListAdapter( + public final ChooserListAdapter createChooserListAdapter( Context context, List<Intent> payloadIntents, Intent[] initialIntents, @@ -64,12 +58,9 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, - Intent referrrerFillInIntent, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { - PackageManager packageManager = - sOverrides.packageManager == null ? context.getPackageManager() - : sOverrides.packageManager; + Intent referrerFillInIntent, + int maxTargetsPerRow) { + return new ChooserListAdapter( context, payloadIntents, @@ -79,13 +70,13 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW createListController(userHandle), userHandle, targetIntent, - referrrerFillInIntent, + referrerFillInIntent, this, - packageManager, + mPackageManager, getEventLog(), maxTargetsPerRow, userHandle, - targetDataLoader, + mTargetDataLoader, null, mFeatureFlags); } @@ -97,17 +88,12 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW @Override public ChooserListAdapter getPersonalListAdapter() { - return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)) - .getListAdapter(); + return mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); } @Override public ChooserListAdapter getWorkListAdapter() { - if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { - return null; - } - return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)) - .getListAdapter(); + return mChooserMultiProfilePagerAdapter.getWorkListAdapter(); } @Override @@ -116,16 +102,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return new ChooserIntegratedDeviceComponents( - /* editSharingComponent=*/ null, - // An arbitrary pre-installed activity that handles this type of intent: - /* nearbySharingComponent=*/ new ComponentName( - "com.google.android.apps.messaging", - ".ui.conversationlist.ShareIntentActivity")); - } - - @Override public UsageStatsManager getUsageStatsManager() { if (mUsm == null) { mUsm = getSystemService(UsageStatsManager.class); @@ -150,14 +126,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - if (sOverrides.mWorkProfileAvailability != null) { - return sOverrides.mWorkProfileAvailability; - } - return super.createWorkProfileAvailabilityManager(); - } - - @Override public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, @Nullable Bundle options) { if (sOverrides.onSafelyStartInternalCallback != null @@ -168,7 +136,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - protected ChooserListController createListController(UserHandle userHandle) { + public final ChooserListController createListController(UserHandle userHandle) { if (userHandle == UserHandle.SYSTEM) { return sOverrides.resolverListController; } @@ -176,14 +144,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - public PackageManager getPackageManager() { - if (sOverrides.createPackageManager != null) { - return sOverrides.createPackageManager.apply(super.getPackageManager()); - } - return super.getPackageManager(); - } - - @Override public Resources getResources() { if (sOverrides.resources != null) { return sOverrides.resources; @@ -212,14 +172,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - protected boolean isWorkProfile() { - if (sOverrides.alternateProfileSetting != 0) { - return sOverrides.alternateProfileSetting == MetricsEvent.MANAGED_PROFILE; - } - return super.isWorkProfile(); - } - - @Override public DisplayResolveInfo createTestDisplayResolveInfo( Intent originalIntent, ResolveInfo pri, @@ -235,16 +187,10 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW } @Override - protected AnnotatedUserHandles computeAnnotatedUserHandles() { - return sOverrides.annotatedUserHandles; - } - - @Override public UserHandle getCurrentUserHandle() { - return mMultiProfilePagerAdapter.getCurrentUserHandle(); + return mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); } - @NonNull @Override public Context createContextAsUser(UserHandle user, int flags) { // return the current context as a work profile doesn't really exist in these tests diff --git a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java index dde2f980..b44f4f91 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ResolverActivityTest.java @@ -25,6 +25,7 @@ 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 androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static com.android.intentresolver.MatcherUtils.first; import static com.android.intentresolver.ResolverWrapperActivity.sOverrides; @@ -49,16 +50,27 @@ 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.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; +import com.android.intentresolver.data.repository.FakeUserRepository; +import com.android.intentresolver.data.repository.UserRepository; +import com.android.intentresolver.data.repository.UserRepositoryModule; +import com.android.intentresolver.inject.ApplicationUser; +import com.android.intentresolver.inject.ProfileParent; +import com.android.intentresolver.shared.model.User; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.google.android.collect.Lists; +import dagger.hilt.android.testing.BindValue; +import dagger.hilt.android.testing.HiltAndroidRule; +import dagger.hilt.android.testing.HiltAndroidTest; +import dagger.hilt.android.testing.UninstallModules; + import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; @@ -73,14 +85,21 @@ import java.util.List; * Resolver activity instrumentation tests */ @RunWith(AndroidJUnit4.class) +@HiltAndroidTest +@UninstallModules(UserRepositoryModule.class) public class ResolverActivityTest { - private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app - .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser(); + private static final UserHandle PERSONAL_USER_HANDLE = + getInstrumentation().getTargetContext().getUser(); private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); + private static final User WORK_PROFILE_USER = + new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK); + + @Rule(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - @Rule + @Rule(order = 1) public ActivityTestRule<ResolverWrapperActivity> mActivityRule = new ActivityTestRule<>(ResolverWrapperActivity.class, false, false); @@ -88,14 +107,30 @@ public class ResolverActivityTest { 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() + getInstrumentation() .getUiAutomation() .adoptShellPermissionIdentity(); sOverrides.reset(); } + @BindValue + @ApplicationUser + public final UserHandle mApplicationUser = PERSONAL_USER_HANDLE; + + @BindValue + @ProfileParent + public final UserHandle mProfileParent = PERSONAL_USER_HANDLE; + + /** For setup of test state, a mutable reference of mUserRepository */ + private final FakeUserRepository mFakeUserRepo = + new FakeUserRepository(List.of( + new User(PERSONAL_USER_HANDLE.getIdentifier(), User.Role.PERSONAL) + )); + + @BindValue + public final UserRepository mUserRepository = mFakeUserRepo; + @Test public void twoOptionsAndUserSelectsOne() throws InterruptedException { Intent sendIntent = createSendImageIntent(); @@ -386,15 +421,14 @@ public class ResolverActivityTest { @Test public void testWorkTab_workTabUsesExpectedAdapter() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -406,9 +440,9 @@ public class ResolverActivityTest { @Test public void testWorkTab_personalTabUsesExpectedAdapter() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List<ResolvedComponentInfo> personalResolvedComponentInfos = createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, WORK_PROFILE_USER_HANDLE); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); @@ -446,7 +480,8 @@ public class ResolverActivityTest { public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, + createResolvedComponentsForTestWithOtherProfile(3, + /* userId */ WORK_PROFILE_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, WORK_PROFILE_USER_HANDLE); @@ -604,7 +639,7 @@ public class ResolverActivityTest { PERSONAL_USER_HANDLE); List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); - sOverrides.isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_PROFILE_USER, false); setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); @@ -652,7 +687,7 @@ public class ResolverActivityTest { setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_PROFILE_USER, false); sOverrides.hasCrossProfileIntents = false; mActivityRule.launchActivity(sendIntent); @@ -722,7 +757,7 @@ public class ResolverActivityTest { setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); Intent sendIntent = createSendImageIntent(); sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; + mFakeUserRepo.updateState(WORK_PROFILE_USER, false); mActivityRule.launchActivity(sendIntent); waitForIdle(); @@ -1050,18 +1085,14 @@ public class ResolverActivityTest { } private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { - AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); - handles - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) - .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); if (workAvailable) { - handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); + mFakeUserRepo.addUser( + new User(WORK_PROFILE_USER_HANDLE.getIdentifier(), User.Role.WORK), true); } if (cloneAvailable) { - handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); + mFakeUserRepo.addUser( + new User(CLONE_PROFILE_USER_HANDLE.getIdentifier(), User.Role.CLONE), true); } - sOverrides.annotatedUserHandles = handles.build(); } private void setupResolverControllers( @@ -1077,21 +1108,14 @@ public class ResolverActivityTest { Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) + eq(PERSONAL_USER_HANDLE))) .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); when(sOverrides.workResolverListController.getResolversForIntentAsUser( Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.isA(List.class), - eq(UserHandle.of(10)))) + eq(WORK_PROFILE_USER_HANDLE))) .thenReturn(new ArrayList<>(workResolvedComponentInfos)); } } diff --git a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java index d1adfba9..b46d8bc3 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -21,9 +21,9 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import android.annotation.Nullable; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.drawable.Drawable; import android.os.Bundle; @@ -31,7 +31,6 @@ import android.os.UserHandle; import android.util.Pair; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.test.espresso.idling.CountingIdlingResource; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -54,10 +53,6 @@ public class ResolverWrapperActivity extends ResolverActivity { private final CountingIdlingResource mLabelIdlingResource = new CountingIdlingResource("LoadLabelTask"); - public ResolverWrapperActivity() { - super(/* isIntentPicker= */ true); - } - public CountingIdlingResource getLabelIdlingResource() { return mLabelIdlingResource; } @@ -69,8 +64,7 @@ public class ResolverWrapperActivity extends ResolverActivity { Intent[] initialIntents, List<ResolveInfo> rList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { return new ResolverListAdapter( context, payloadIntents, @@ -82,7 +76,7 @@ public class ResolverWrapperActivity extends ResolverActivity { payloadIntents.get(0), // TODO: extract upstream this, userHandle, - new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource)); + new TargetDataLoaderWrapper(mTargetDataLoader, mLabelIdlingResource)); } @Override @@ -93,27 +87,16 @@ public class ResolverWrapperActivity extends ResolverActivity { return super.createCrossProfileIntentsChecker(); } - @Override - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - if (sOverrides.mWorkProfileAvailability != null) { - return sOverrides.mWorkProfileAvailability; - } - return super.createWorkProfileAvailabilityManager(); - } - ResolverListAdapter getAdapter() { return mMultiProfilePagerAdapter.getActiveListAdapter(); } ResolverListAdapter getPersonalListAdapter() { - return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)); + return mMultiProfilePagerAdapter.getPersonalListAdapter(); } ResolverListAdapter getWorkListAdapter() { - if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { - return null; - } - return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)); + return mMultiProfilePagerAdapter.getWorkListAdapter(); } @Override @@ -142,96 +125,35 @@ public class ResolverWrapperActivity extends ResolverActivity { 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 AnnotatedUserHandles computeAnnotatedUserHandles() { - return sOverrides.annotatedUserHandles; - } - @Override - public void startActivityAsUser( - @NonNull Intent intent, - Bundle options, - @NonNull UserHandle user - ) { + public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { super.startActivityAsUser(intent, options, user); } - @Override - protected List<UserHandle> getResolverRankerServiceUserHandleListInternal(UserHandle - userHandle) { - return super.getResolverRankerServiceUserHandleListInternal(userHandle); - } - /** * 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 { + public static class OverrideData { @SuppressWarnings("Since15") - public Function<PackageManager, PackageManager> createPackageManager; public Function<Pair<TargetInfo, UserHandle>, Boolean> onSafelyStartInternalCallback; public ResolverListController resolverListController; public ResolverListController workResolverListController; public Boolean isVoiceInteraction; - public AnnotatedUserHandles annotatedUserHandles; - public Integer myUserId; public boolean hasCrossProfileIntents; - public boolean isQuietModeEnabled; - public WorkProfileAvailabilityManager mWorkProfileAvailability; public CrossProfileIntentsChecker mCrossProfileIntentsChecker; public void reset() { onSafelyStartInternalCallback = null; isVoiceInteraction = null; - createPackageManager = null; resolverListController = mock(ResolverListController.class); workResolverListController = mock(ResolverListController.class); - annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) - .setPersonalProfileUserHandle(UserHandle.SYSTEM) - .build(); - myUserId = null; hasCrossProfileIntents = true; - isQuietModeEnabled = false; - - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) .thenAnswer(invocation -> hasCrossProfileIntents); @@ -249,11 +171,12 @@ public class ResolverWrapperActivity extends ResolverActivity { } @Override - public void loadAppTargetIcon( + @Nullable + public Drawable getOrLoadAppTargetIcon( @NonNull DisplayResolveInfo info, @NonNull UserHandle userHandle, @NonNull Consumer<Drawable> callback) { - mTargetDataLoader.loadAppTargetIcon(info, userHandle, callback); + return mTargetDataLoader.getOrLoadAppTargetIcon(info, userHandle, callback); } @Override diff --git a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java deleted file mode 100644 index f597d7f2..00000000 --- a/tests/activity/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ /dev/null @@ -1,3127 +0,0 @@ -/* - * 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 android.app.Activity.RESULT_OK; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.longClick; -import static androidx.test.espresso.action.ViewActions.swipeUp; -import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; -import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; - -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; -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 static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; -import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; -import static com.android.intentresolver.MatcherUtils.first; - -import static com.google.common.truth.Truth.assertThat; -import static com.google.common.truth.Truth.assertWithMessage; - -import static junit.framework.Assert.assertNull; - -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.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.app.PendingIntent; -import android.app.usage.UsageStatsManager; -import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipDescription; -import android.content.ClipboardManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager.ShareShortcutInfo; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.drawable.Icon; -import android.net.Uri; -import android.os.Bundle; -import android.os.UserHandle; -import android.platform.test.annotations.RequiresFlagsEnabled; -import android.platform.test.flag.junit.CheckFlagsRule; -import android.platform.test.flag.junit.DeviceFlagsValueProvider; -import android.provider.DeviceConfig; -import android.service.chooser.ChooserAction; -import android.service.chooser.ChooserTarget; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.BackgroundColorSpan; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; -import android.text.style.UnderlineSpan; -import android.util.Pair; -import android.util.SparseArray; -import android.view.View; -import android.view.WindowManager; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.test.espresso.contrib.RecyclerViewActions; -import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; -import androidx.test.espresso.matcher.ViewMatchers; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.rule.ActivityTestRule; - -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.logging.EventLog; -import com.android.intentresolver.logging.FakeEventLog; -import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; - -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.Matchers; -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; - -/** - * Instrumentation tests for ChooserActivity. - * <p> - * Legacy test suite migrated from framework CoreTests. - * <p> - */ -@RunWith(Parameterized.class) -@HiltAndroidTest -public class UnbundledChooserActivityTest { - - private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { - return (FakeEventLog) activity.mEventLog; - } - - private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser(); - private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); - private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); - - private static final Function<PackageManager, PackageManager> DEFAULT_PM = pm -> pm; - private static final Function<PackageManager, PackageManager> NO_APP_PREDICTION_SERVICE_PM = - pm -> { - PackageManager mock = Mockito.spy(pm); - when(mock.getAppPredictionServicePackageName()).thenReturn(null); - return mock; - }; - - @Parameterized.Parameters - public static Collection packageManagers() { - return Arrays.asList(new Object[][] { - // Default PackageManager - { DEFAULT_PM }, - // No App Prediction Service - { NO_APP_PREDICTION_SERVICE_PM} - }); - } - - private static final String TEST_MIME_TYPE = "application/TestType"; - - private static final int CONTENT_PREVIEW_IMAGE = 1; - private static final int CONTENT_PREVIEW_FILE = 2; - private static final int CONTENT_PREVIEW_TEXT = 3; - - @Rule(order = 0) - public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); - - @Rule(order = 1) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - - @Rule(order = 2) - public ActivityTestRule<ChooserWrapperActivity> mActivityRule = - new ActivityTestRule<>(ChooserWrapperActivity.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). - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - cleanOverrideData(); - mHiltAndroidRule.inject(); - } - - private final Function<PackageManager, PackageManager> mPackageManagerOverride; - - public UnbundledChooserActivityTest( - Function<PackageManager, PackageManager> packageManagerOverride) { - mPackageManagerOverride = packageManagerOverride; - } - - 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; - - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(true)); - } - - @Test - public void customTitle() throws InterruptedException { - Intent viewIntent = createViewTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( - Intent.createChooser(viewIntent, "chooser test")); - - waitForIdle(); - assertThat(activity.getAdapter().getCount(), is(2)); - assertThat(activity.getAdapter().getServiceTargetCount(), is(0)); - onView(withId(android.R.id.title)).check(matches(withText("chooser test"))); - } - - @Test - public void customTitleIgnoredForSendIntents() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); - waitForIdle(); - onView(withId(android.R.id.title)) - .check(matches(withText(R.string.whichSendApplication))); - } - - @Test - public void emptyTitle() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(android.R.id.title)) - .check(matches(withText(R.string.whichSendApplication))); - } - - @Test - public void test_shareRichTextWithRichTitle_richTextAndRichTitleDisplayed() { - CharSequence title = new SpannableStringBuilder() - .append("Rich", new UnderlineSpan(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE) - .append( - "Title", - new ForegroundColorSpan(Color.RED), - Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - CharSequence sharedText = new SpannableStringBuilder() - .append( - "Rich", - new BackgroundColorSpan(Color.YELLOW), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - .append( - "Text", - new StyleSpan(Typeface.ITALIC), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - Intent sendIntent = createSendTextIntent(); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - sendIntent.putExtra(Intent.EXTRA_TITLE, title); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.content_preview_title)) - .check((view, e) -> { - assertThat(view).isInstanceOf(TextView.class); - CharSequence text = ((TextView) view).getText(); - assertThat(text).isInstanceOf(Spanned.class); - Spanned spanned = (Spanned) text; - assertThat(spanned.getSpans(0, spanned.length(), Object.class)) - .hasLength(2); - assertThat(spanned.getSpans(0, 4, UnderlineSpan.class)).hasLength(1); - assertThat(spanned.getSpans(4, spanned.length(), ForegroundColorSpan.class)) - .hasLength(1); - }); - - onView(withId(com.android.internal.R.id.content_preview_text)) - .check((view, e) -> { - assertThat(view).isInstanceOf(TextView.class); - CharSequence text = ((TextView) view).getText(); - assertThat(text).isInstanceOf(Spanned.class); - Spanned spanned = (Spanned) text; - assertThat(spanned.getSpans(0, spanned.length(), Object.class)) - .hasLength(2); - assertThat(spanned.getSpans(0, 4, BackgroundColorSpan.class)).hasLength(1); - assertThat(spanned.getSpans(4, spanned.length(), StyleSpan.class)).hasLength(1); - }); - } - - @Test - public void emptyPreviewTitleAndThumbnail() throws InterruptedException { - Intent sendIntent = createSendTextIntentWithPreview(null, null); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleWithoutThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(withText(previewTitle))); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleWithInvalidThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, - Uri.parse("tel:(+49)12345789")); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - 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()))); - } - - @Test - public void visiblePreviewTitleAndThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Uri uri = Uri.parse( - "android.resource://com.android.frameworks.coretests/" - + com.android.intentresolver.tests.R.drawable.test320x240); - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - 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())); - } - - @Test @Ignore - public void twoOptionsAndUserSelectsOne() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - assertThat(activity.getAdapter().getCount(), is(2)); - onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void fourOptionsStackedIntoOneTarget() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - - // create just enough targets to ensure the a-z list should be shown - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1); - - // next create 4 targets in a single app that should be stacked into a single target - String packageName = "xxx.yyy"; - String appName = "aaa"; - ComponentName cn = new ComponentName(packageName, appName); - Intent intent = new Intent("fakeIntent"); - List<ResolvedComponentInfo> infosToStack = new ArrayList<>(); - for (int i = 0; i < 4; i++) { - ResolveInfo resolveInfo = ResolverDataProvider.createResolveInfo(i, - UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); - resolveInfo.activityInfo.applicationInfo.name = appName; - resolveInfo.activityInfo.applicationInfo.packageName = packageName; - resolveInfo.activityInfo.packageName = packageName; - resolveInfo.activityInfo.name = "ccc" + i; - infosToStack.add(new ResolvedComponentInfo(cn, intent, resolveInfo)); - } - resolvedComponentInfos.addAll(infosToStack); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // expect 1 unique targets + 1 group + 4 ranked app targets - assertThat(activity.getAdapter().getCount(), is(6)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - onView(allOf(withText(appName), hasSibling(withText("")))).perform(click()); - waitForIdle(); - - // clicking will launch a dialog to choose the activity within the app - onView(withText(appName)).check(matches(isDisplayed())); - int i = 0; - for (ResolvedComponentInfo rci: infosToStack) { - onView(withText("ccc" + i)).check(matches(isDisplayed())); - ++i; - } - } - - @Test @Ignore - public void updateChooserCountsAndModelAfterUserSelection() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - UsageStatsManager usm = activity.getUsageStatsManager(); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .topK(any(List.class), anyInt()); - assertThat(activity.getIsSelected(), is(false)); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - return true; - }; - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo( - sendIntent, toChoose, "testLabel", "testInfo", sendIntent); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateChooserCounts(Mockito.anyString(), any(UserHandle.class), - Mockito.anyString()); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateModel(testDri); - assertThat(activity.getIsSelected(), is(true)); - } - - @Ignore // b/148158199 - @Test - public void noResultsFromPackageManager() { - setupResolverControllers(null); - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - - waitForIdle(); - assertThat(activity.isFinishing(), is(false)); - - 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() - ); - // backward compatibility. looks like we finish when data is empty after package change - assertThat(activity.isFinishing(), is(true)); - } - - @Test - public void autoLaunchSingleResult() throws InterruptedException { - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1); - setupResolverControllers(resolvedComponentInfos); - - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - assertThat(chosen[0], is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat(activity.isFinishing(), is(true)); - } - - @Test @Ignore - public void hasOtherProfileOneOption() { - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); - Intent sendIntent = createSendTextIntent(); - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(1)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = 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); - waitForIdle(); - - onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = 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(3); - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void hasLastChosenActivityAndOtherProfile() throws Exception { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = 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(3); - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_ExcludeText() { - Uri uri = createTestContentProviderUri(null, "image/png"); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); - - AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse(); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_RemoveAndAddBackText() { - Uri uri = createTestContentProviderUri("application/pdf", "image/png"); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - final String text = "https://google.com/search?q=google"; - sendIntent.putExtra(Intent.EXTRA_TEXT, text); - - List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); - - onView(withId(R.id.include_text_action)) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.content_preview_text)).check(matches(withText(text))); - - AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - Intent alternativeIntent = createSendTextIntent(); - final String text = "alternative intent"; - alternativeIntent.putExtra(Intent.EXTRA_TEXT, text); - - List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - alternativeIntent, PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); - } - - @Test - @Ignore("b/285309527") - public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.image_view)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - onView(withId(R.id.content_preview_text)) - .check(matches(allOf(isDisplayed(), withText("Image only")))); - } - - @Test - public void copyTextToClipboard() { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.copy)).check(matches(isDisplayed())); - onView(withId(R.id.copy)).perform(click()); - ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( - Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - assertThat(clipData).isNotNull(); - assertThat(clipData.getItemAt(0).getText()).isEqualTo("testing intent sending"); - - ClipDescription clipDescription = clipData.getDescription(); - assertThat("text/plain", is(clipDescription.getMimeType(0))); - - assertEquals(mActivityRule.getActivityResult().getResultCode(), RESULT_OK); - } - - @Test - public void copyTextToClipboardLogging() { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.copy)).check(matches(isDisplayed())); - onView(withId(R.id.copy)).perform(click()); - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getActionSelected()) - .isEqualTo(new FakeEventLog.ActionSelected( - /* targetType = */ EventLog.SELECTION_TYPE_COPY)); - } - - @Test - @Ignore - public void testNearbyShareLogging() throws Exception { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - 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. - } - - - - @Test @Ignore - public void testEditImageLogs() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - 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. - } - - - @Test - public void oneVisibleImagePreview() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getAdapter().getItemCount(), is(1)); - assertThat(recyclerView.getChildCount(), is(1)); - View imageView = recyclerView.getChildAt(0); - Rect rect = new Rect(); - boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); - assertThat( - "image preview view is not fully visible", - isPartiallyVisible - && rect.width() == imageView.getWidth() - && rect.height() == imageView.getHeight()); - }); - } - - @Test - public void allThumbnailsFailedToLoad_hidePreview() { - Uri uri = createTestContentProviderUri("image/jpg", null); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - } - - @Test(timeout = 4_000) - public void testSlowUriMetadata_fallbackToFilePreview() { - Uri uri = createTestContentProviderUri( - "application/pdf", "image/png", /*streamTypeTimeout=*/8_000); - ArrayList<Uri> uris = new ArrayList<>(1); - uris.add(uri); - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - // The preview type resolution is expected to timeout and default to file preview, otherwise - // the test should timeout. - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test(timeout = 4_000) - public void testSendManyFilesWithSmallMetadataDelayAndOneImage_fallbackToFilePreviewUi() { - Uri fileUri = createTestContentProviderUri( - "application/pdf", "application/pdf", /*streamTypeTimeout=*/300); - Uri imageUri = createTestContentProviderUri("application/pdf", "image/png"); - ArrayList<Uri> uris = new ArrayList<>(50); - for (int i = 0; i < 49; i++) { - uris.add(fileUri); - } - uris.add(imageUri); - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(imageUri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - // The preview type resolution is expected to timeout and default to file preview, otherwise - // the test should timeout. - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - waitForIdle(); - - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("image.png"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void testManyVisibleImagePreview_ScrollableImagePreview() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .perform(RecyclerViewActions.scrollToLastPosition()) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size())); - }); - } - - @Test(timeout = 4_000) - public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart() { - Uri imgOneUri = createTestContentProviderUri("image/png", null); - Uri imgTwoUri = createTestContentProviderUri("image/png", null) - .buildUpon() - .path("image-2.png") - .build(); - Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 8_000); - ArrayList<Uri> uris = new ArrayList<>(2); - // two large previews to fill the screen and be presented right away and one - // document that would be delayed by the URI metadata reading - uris.add(imgOneUri); - uris.add(imgTwoUri); - uris.add(docUri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - Map<Uri, Bitmap> bitmaps = new HashMap<>(); - bitmaps.put(imgOneUri, createWideBitmap(Color.RED)); - bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN)); - bitmaps.put(docUri, createWideBitmap(Color.BLUE)); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(bitmaps); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // the preview type is expected to be resolved quickly based on the first provided URI - // metadata. If, instead, it is dependent on the third URI metadata, the test should either - // timeout or (more probably due to inner timeout) default to file preview type; anyway the - // test will fail. - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.scrollable_image_preview)) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getChildCount()).isAtLeast(1); - // the first view is a preview - View imageView = recyclerView.getChildAt(0).findViewById(R.id.image); - assertThat(imageView).isNotNull(); - }) - .perform(RecyclerViewActions.scrollToLastPosition()) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getChildCount()).isAtLeast(1); - // check that the last view is a loading indicator - View loadingIndicator = - recyclerView.getChildAt(recyclerView.getChildCount() - 1); - assertThat(loadingIndicator).isNotNull(); - }); - waitForIdle(); - } - - @Test - public void testImageAndTextPreview() { - final Uri uri = createTestContentProviderUri("image/png", null); - final String sharedText = "text-" + System.currentTimeMillis(); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText)) - .check(matches(isDisplayed())); - } - - @Test - public void test_shareImageWithRichText_RichTextIsDisplayed() { - final Uri uri = createTestContentProviderUri("image/png", null); - final CharSequence sharedText = new SpannableStringBuilder() - .append( - "text-", - new StyleSpan(Typeface.BOLD_ITALIC), - Spannable.SPAN_INCLUSIVE_EXCLUSIVE) - .append( - Long.toString(System.currentTimeMillis()), - new ForegroundColorSpan(Color.RED), - Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText.toString())) - .check(matches(isDisplayed())) - .check((view, e) -> { - if (e != null) { - throw e; - } - assertThat(view).isInstanceOf(TextView.class); - CharSequence text = ((TextView) view).getText(); - assertThat(text).isInstanceOf(Spanned.class); - Spanned spanned = (Spanned) text; - Object[] spans = spanned.getSpans(0, text.length(), Object.class); - assertThat(spans).hasLength(2); - assertThat(spanned.getSpans(0, 5, StyleSpan.class)).hasLength(1); - assertThat(spanned.getSpans(5, text.length(), ForegroundColorSpan.class)) - .hasLength(1); - }); - } - - @Test - public void testTextPreviewWhenTextIsSharedWithMultipleImages() { - final Uri uri = createTestContentProviderUri("image/png", null); - final String sharedText = "text-" + System.currentTimeMillis(); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - Mockito.any(UserHandle.class))) - .thenReturn(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText)).check(matches(isDisplayed())); - } - - @Test - public void testOnCreateLogging() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); - assertThat(event).isNotNull(); - assertThat(event.isWorkProfile()).isFalse(); - assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); - } - - @Test - public void testOnCreateLoggingFromWorkProfile() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().alternateProfileSetting = - MetricsEvent.MANAGED_PROFILE; - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); - assertThat(event).isNotNull(); - assertThat(event.isWorkProfile()).isTrue(); - assertThat(event.getTargetMimeType()).isEqualTo(TEST_MIME_TYPE); - } - - @Test - public void testEmptyPreviewLogging() { - Intent sendIntent = createSendTextIntentWithPreview(null, null); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, - "empty preview logger test")); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - FakeEventLog.ChooserActivityShown event = eventLog.getChooserActivityShown(); - assertThat(event).isNotNull(); - assertThat(event.isWorkProfile()).isFalse(); - assertThat(event.getTargetMimeType()).isNull(); - } - - @Test - public void testTitlePreviewLogging() { - Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getActionShareWithPreview()) - .isEqualTo(new FakeEventLog.ActionShareWithPreview( - /* previewType = */ CONTENT_PREVIEW_TEXT)); - } - - @Test - public void testImagePreviewLogging() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - ChooserWrapperActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getActionShareWithPreview()) - .isEqualTo(new FakeEventLog.ActionShareWithPreview( - /* previewType = */ CONTENT_PREVIEW_IMAGE)); - } - - @Test - public void oneVisibleFilePreview() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - - @Test - public void moreThanOneVisibleFilePreview() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 2 more files"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void contentProviderThrowSecurityException() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - ChooserActivityOverrideData.getInstance().resolverForceException = true; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void contentProviderReturnsNoColumns() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Cursor cursor = mock(Cursor.class); - when(cursor.getCount()).thenReturn(1); - Mockito.doNothing().when(cursor).close(); - when(cursor.moveToFirst()).thenReturn(true); - when(cursor.getColumnIndex(Mockito.anyString())).thenReturn(-1); - - ChooserActivityOverrideData.getInstance().resolverCursor = cursor; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 1 more file"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void testGetBaseScore() { - final float testBaseScore = 0.89f; - - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getScore(Mockito.isA(DisplayResolveInfo.class))) - .thenReturn(testBaseScore); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - final DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo( - sendIntent, - ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), - "testLabel", - "testInfo", - sendIntent); - final ChooserListAdapter adapter = activity.getAdapter(); - - assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_DEFAULT), is(testBaseScore)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_CHOOSER_TARGET), is(testBaseScore)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE), - is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER), - is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test - public void testDirectTargetSelectionLogging() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - ChooserWrapperActivity activity = - 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)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List<ChooserTarget> serviceTargets = createDirectShareTargets(1, ""); - 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(); - - 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(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getShareTargetSelected()).hasSize(1); - FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); - assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(-1); - var hashResult = call.getDirectTargetHashed(); - var hash = hashResult == null ? "" : hashResult.hashedString; - assertWithMessage("Hash is not predictable but must be obfuscated") - .that(hash).isNotEqualTo(name); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test - public void testDirectTargetLoggingWithRankedAppTarget() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - ChooserWrapperActivity activity = - 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)).updateAppTargets(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<>() - ); - 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 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(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getShareTargetSelected()).hasSize(1); - FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); - - assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - assertThat(call.getDirectTargetAlsoRanked()).isEqualTo(0); - } - - @Test - public void testShortcutTargetWithApplyAppLimits() { - // Set up resources - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // 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)).updateAppTargets(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<>() - ); - 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 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 - public void testShortcutTargetWithoutApplyAppLimits() { - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(false)); - // Set up resources - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // 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)).updateAppTargets(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<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - 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 - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(1).when(resources).getInteger(R.integer.config_maxShortcutTargetsPerApp); - - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - // 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)).updateAppTargets(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)); - - // Switch to work profile and ensure that the target *doesn't* show up there. - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - for (int i = 0; i < activity.getWorkListAdapter().getCount(); i++) { - assertThat( - "Chooser target should not show up in opposite profile", - activity.getWorkListAdapter().getItem(i).getDisplayLabel(), - not(callerTargetLabel)); - } - } - - @Test - public void testLaunchWithCustomAction() throws InterruptedException { - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String customActionLabel = "Custom Action"; - final String testAction = "test-broadcast-receiver-action"; - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, - new ChooserAction[] { - new ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - customActionLabel, - PendingIntent.getBroadcast( - testContext, - 123, - new Intent(testAction), - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)) - .build() - }); - // Start activity - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - final CountDownLatch broadcastInvoked = new CountDownLatch(1); - BroadcastReceiver testReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - broadcastInvoked.countDown(); - } - }; - testContext.registerReceiver(testReceiver, new IntentFilter(testAction), - Context.RECEIVER_EXPORTED); - - try { - onView(withText(customActionLabel)).perform(click()); - assertTrue("Timeout waiting for broadcast", - broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); - } finally { - testContext.unregisterReceiver(testReceiver); - } - } - - @Test - public void testLaunchWithShareModification() throws InterruptedException { - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String modifyShareAction = "test-broadcast-receiver-action"; - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - String label = "modify share"; - PendingIntent pendingIntent = PendingIntent.getBroadcast( - testContext, - 123, - new Intent(modifyShareAction), - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); - ChooserAction action = new ChooserAction.Builder(Icon.createWithBitmap( - createBitmap()), label, pendingIntent).build(); - chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, - action); - // Start activity - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - final CountDownLatch broadcastInvoked = new CountDownLatch(1); - BroadcastReceiver testReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - broadcastInvoked.countDown(); - } - }; - testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction), - Context.RECEIVER_EXPORTED); - - try { - onView(withText(label)).perform(click()); - assertTrue("Timeout waiting for broadcast", - broadcastInvoked.await(5000, TimeUnit.MILLISECONDS)); - - } finally { - testContext.unregisterReceiver(testReceiver); - } - } - - @Test - public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { - updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); - givenAppTargets(/* appCount= */ 16); - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - updateMaxTargetsPerRowResource(/* targetsPerRow= */ 6); - InstrumentationRegistry.getInstrumentation() - .runOnMainSync(() -> activity.onConfigurationChanged( - InstrumentationRegistry.getInstrumentation() - .getContext().getResources().getConfiguration())); - - waitForIdle(); - onView(withId(com.android.internal.R.id.resolver_list)) - .check(matches(withGridColumnCount(6))); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore - public void testDirectTargetLoggingWithAppTargetNotRankedPortrait() - throws InterruptedException { - testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_PORTRAIT, 4); - } - - @Test @Ignore - public void testDirectTargetLoggingWithAppTargetNotRankedLandscape() - throws InterruptedException { - testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_LANDSCAPE, 8); - } - - private void testDirectTargetLoggingWithAppTargetNotRanked( - int orientation, int appTargetsExpected) { - Configuration configuration = - new Configuration(InstrumentationRegistry.getInstrumentation().getContext() - .getResources().getConfiguration()); - configuration.orientation = orientation; - - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(configuration).when(resources).getConfiguration(); - - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(15); - setupResolverControllers(resolvedComponentInfos); - - // Create direct share target - List<ChooserTarget> serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE); - - // Start activity - ChooserWrapperActivity activity = - 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), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) - ); - - assertThat( - String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", - appTargetsExpected + 16, appTargetsExpected), - activity.getAdapter().getCount(), is(appTargetsExpected + 16)); - 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)); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - var invocations = eventLog.getShareTargetSelected(); - assertWithMessage("Only one ShareTargetSelected event logged") - .that(invocations).hasSize(1); - FakeEventLog.ShareTargetSelected call = invocations.get(0); - assertWithMessage("targetType should be SELECTION_TYPE_SERVICE") - .that(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - assertWithMessage( - "The packages shouldn't match for app target and direct target") - .that(call.getDirectTargetAlsoRanked()).isEqualTo(-1); - } - - @Test - public void testWorkTab_displayedWhenWorkProfileUserAvailable() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - onView(withId(android.R.id.tabs)).check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed()))); - } - - @Test - public void testWorkTab_eachTabUsesExpectedAdapter() { - int personalProfileTargets = 3; - int otherProfileTargets = 1; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile( - personalProfileTargets + otherProfileTargets, /* userID */ 10); - int workProfileTargets = 4; - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest( - workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); - 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)); - } - - @Test - public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); - } - - @Test @Ignore - public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - int workProfileTargets = 4; - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - 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()); - waitForIdle(); - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_workProfileDisabled_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.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() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.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 - @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) - public void testWorkTab_previewIsScrollable() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(300); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); - waitForIdle(); - - onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) - .check(matches(isDisplayed())); - - onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); - waitForIdle(); - - onView(withId(com.android.intentresolver.R.id.chooser_headline_row_container)) - .check(matches(isCompletelyDisplayed())); - onView(withId(com.android.intentresolver.R.id.headline)) - .check(matches(isDisplayed())); - onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) - .check(matches(not(isDisplayed()))); - } - - @Ignore // b/220067877 - @Test - public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.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 testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.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 @Ignore("b/222124533") - public void testAppTargetLogging() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // TODO(b/222124533): other test cases use a timeout to make sure that the UI is fully - // populated; without one, this test flakes. Ideally we should address the need for a - // timeout everywhere instead of introducing one to fix this particular test. - - assertThat(activity.getAdapter().getCount(), is(2)); - onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test - public void testDirectTargetLogging() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - - // Start activity - ChooserWrapperActivity activity = - 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)) - .updateAppTargets(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<>() - ); - 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(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - FakeEventLog eventLog = getEventLog(activity); - assertThat(eventLog.getShareTargetSelected()).hasSize(1); - FakeEventLog.ShareTargetSelected call = eventLog.getShareTargetSelected().get(0); - assertThat(call.getTargetType()).isEqualTo(EventLog.SELECTION_TYPE_SERVICE); - } - - @Test - public void testDirectTargetPinningDialog() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor<DisplayResolveInfo[]> appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)) - .updateAppTargets(appTargets.capture()); - - // send shortcuts - List<ChooserTarget> serviceTargets = createDirectShareTargets( - 1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - // TODO: test another value as well - false, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - // Long-click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)).perform(longClick()); - waitForIdle(); - - onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed())); - } - - @Test @Ignore - public void testEmptyDirectRowLogging() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, 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 - Thread.sleep(3000); - - assertThat("Chooser should have 2 app targets", - activity.getAdapter().getCount(), is(2)); - assertThat("Chooser should have no direct targets", - activity.getAdapter().getSelectableServiceTargetCount(), is(0)); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Ignore // b/220067877 - @Test - public void testCopyTextToClipboardLogging() throws Exception { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - 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. - } - - @Test @Ignore("b/222124533") - public void testSwitchProfileLogging() throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withText(R.string.resolver_personal_tab)).perform(click()); - waitForIdle(); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test - public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); - waitForIdle(); - - assertNull(chosen[0]); - } - - @Test - public void testOneInitialIntent_noAutolaunch() { - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(1); - setupResolverControllers(personalResolvedComponentInfos); - Intent chooserIntent = createChooserIntent(createSendTextIntent(), - new Intent[] {new Intent("action.fake")}); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - ResolveInfo ri = createFakeResolveInfo(); - when( - ChooserActivityOverrideData - .getInstance().packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(ri); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - assertNull(chosen[0]); - assertThat(activity - .getPersonalListAdapter().getCallerTargetCount(), is(1)); - } - - @Test - public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 1; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(createSendTextIntent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - assertThat(activity.getPersonalListAdapter().getCallerTargetCount(), is(2)); - assertThat(activity.getWorkListAdapter().getCallerTargetCount(), is(0)); - } - - @Test - public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); - - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); - - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - onView(withId(com.android.internal.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 testDeduplicateCallerTargetRankedTarget() { - // Create 4 ranked app targets. - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(4); - setupResolverControllers(personalResolvedComponentInfos); - // Create caller target which is duplicate with one of app targets - Intent chooserIntent = createChooserIntent(createSendTextIntent(), - new Intent[] {new Intent("action.fake")}); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(0, - UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(ri); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - // Total 4 targets (1 caller target, 3 ranked targets) - assertThat(activity.getAdapter().getCount(), is(4)); - assertThat(activity.getAdapter().getCallerTargetCount(), is(1)); - assertThat(activity.getAdapter().getRankedTargetCount(), is(3)); - } - - @Test - public void test_query_shortcut_loader_for_the_selected_tab() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - 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(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - waitForIdle(); - - verify(personalProfileShortcutLoader, times(1)).updateAppTargets(any()); - - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - verify(workProfileShortcutLoader, times(1)).updateAppTargets(any()); - } - - @Test - public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - - final IChooserWrapper activity = (IChooserWrapper) mActivityRule - .launchActivity(Intent.createChooser(sendIntent, "personalProfileTest")); - waitForIdle(); - - assertThat(activity.getPersonalListAdapter().getUserHandle(), is(PERSONAL_USER_HANDLE)); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest( - 4); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "multi tab test")); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); - } - - private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { - Intent chooserIntent = new Intent(); - chooserIntent.setAction(Intent.ACTION_CHOOSER); - chooserIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - chooserIntent.putExtra(Intent.EXTRA_TITLE, "some title"); - chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); - chooserIntent.setType("text/plain"); - if (initialIntents != null) { - chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, initialIntents); - } - return chooserIntent; - } - - /* This is a "test of a test" to make sure that our inherited test class - * is successfully configured to operate on the unbundled-equivalent - * ChooserWrapperActivity. - * - * TODO: remove after unbundling is complete. - */ - @Test - public void testWrapperActivityHasExpectedConcreteType() { - final ChooserActivity activity = mActivityRule.launchActivity( - Intent.createChooser(new Intent("ACTION_FOO"), "foo")); - waitForIdle(); - assertThat(activity).isInstanceOf(ChooserWrapperActivity.class); - } - - private ResolveInfo createFakeResolveInfo() { - ResolveInfo ri = new ResolveInfo(); - ri.activityInfo = new ActivityInfo(); - ri.activityInfo.name = "FakeActivityName"; - ri.activityInfo.packageName = "fake.package.name"; - ri.activityInfo.applicationInfo = new ApplicationInfo(); - ri.activityInfo.applicationInfo.packageName = "fake.package.name"; - ri.userHandle = UserHandle.CURRENT; - return ri; - } - - private Intent createSendTextIntent() { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.setType("text/plain"); - return sendIntent; - } - - private Intent createSendImageIntent(Uri imageThumbnail) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_STREAM, imageThumbnail); - sendIntent.setType("image/png"); - if (imageThumbnail != null) { - ClipData.Item clipItem = new ClipData.Item(imageThumbnail); - sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); - } - - return sendIntent; - } - - private Uri createTestContentProviderUri( - @Nullable String mimeType, @Nullable String streamType) { - return createTestContentProviderUri(mimeType, streamType, 0); - } - - private Uri createTestContentProviderUri( - @Nullable String mimeType, @Nullable String streamType, long streamTypeTimeout) { - String packageName = - InstrumentationRegistry.getInstrumentation().getContext().getPackageName(); - Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png") - .buildUpon(); - if (mimeType != null) { - builder.appendQueryParameter(TestContentProvider.PARAM_MIME_TYPE, mimeType); - } - if (streamType != null) { - builder.appendQueryParameter(TestContentProvider.PARAM_STREAM_TYPE, streamType); - } - if (streamTypeTimeout > 0) { - builder.appendQueryParameter( - TestContentProvider.PARAM_STREAM_TYPE_TIMEOUT, - Long.toString(streamTypeTimeout)); - } - return builder.build(); - } - - private Intent createSendTextIntentWithPreview(String title, Uri imageThumbnail) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.putExtra(Intent.EXTRA_TITLE, title); - if (imageThumbnail != null) { - ClipData.Item clipItem = new ClipData.Item(imageThumbnail); - sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); - } - - return sendIntent; - } - - private Intent createSendUriIntentWithPreview(ArrayList<Uri> uris) { - Intent sendIntent = new Intent(); - - if (uris.size() > 1) { - sendIntent.setAction(Intent.ACTION_SEND_MULTIPLE); - sendIntent.putExtra(Intent.EXTRA_STREAM, uris); - } else { - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_STREAM, uris.get(0)); - } - - return sendIntent; - } - - private Intent createViewTextIntent() { - Intent viewIntent = new Intent(); - viewIntent.setAction(Intent.ACTION_VIEW); - viewIntent.putExtra(Intent.EXTRA_TEXT, "testing intent viewing"); - return viewIntent; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, PERSONAL_USER_HANDLE)); - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsWithCloneProfileForTest( - int numberOfResults, - UserHandle resolvedForPersonalUser, - UserHandle resolvedForClonedUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < 1; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForPersonalUser)); - } - for (int i = 1; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForClonedUser)); - } - 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, - PERSONAL_USER_HANDLE)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - PERSONAL_USER_HANDLE)); - } - } - 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, - PERSONAL_USER_HANDLE)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - PERSONAL_USER_HANDLE)); - } - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTestWithUserId( - int numberOfResults, int userId) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - PERSONAL_USER_HANDLE)); - } - return infoList; - } - - private List<ChooserTarget> createDirectShareTargets(int numberOfResults, String packageName) { - Icon icon = Icon.createWithBitmap(createBitmap()); - String testTitle = "testTitle"; - List<ChooserTarget> targets = new ArrayList<>(); - for (int i = 0; i < numberOfResults; i++) { - ComponentName componentName; - if (packageName.isEmpty()) { - componentName = ResolverDataProvider.createComponentName(i); - } else { - componentName = new ComponentName(packageName, packageName + ".class"); - } - ChooserTarget tempTarget = new ChooserTarget( - testTitle + i, - icon, - (float) (1 - ((i + 1) / 10.0)), - componentName, - null); - targets.add(tempTarget); - } - return targets; - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private Bitmap createBitmap() { - return createBitmap(200, 200); - } - - private Bitmap createWideBitmap() { - return createWideBitmap(Color.RED); - } - - private Bitmap createWideBitmap(int bgColor) { - WindowManager windowManager = InstrumentationRegistry.getInstrumentation() - .getTargetContext() - .getSystemService(WindowManager.class); - int width = 3000; - if (windowManager != null) { - Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); - width = bounds.width() + 200; - } - return createBitmap(width, 100, bgColor); - } - - private Bitmap createBitmap(int width, int height) { - return createBitmap(width, height, Color.RED); - } - - private Bitmap createBitmap(int width, int height, int bgColor) { - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - - Paint paint = new Paint(); - paint.setColor(bgColor); - paint.setStyle(Paint.Style.FILL); - canvas.drawPaint(paint); - - paint.setColor(Color.WHITE); - paint.setAntiAlias(true); - paint.setTextSize(14.f); - paint.setTextAlign(Paint.Align.CENTER); - canvas.drawText("Hi!", (width / 2.f), (height / 2.f), paint); - - return bitmap; - } - - private List<ShareShortcutInfo> createShortcuts(Context context) { - Intent testIntent = new Intent("TestIntent"); - - List<ShareShortcutInfo> shortcuts = new ArrayList<>(); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut1") - .setIntent(testIntent).setShortLabel("label1").setRank(3).build(), // 0 2 - new ComponentName("package1", "class1"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut2") - .setIntent(testIntent).setShortLabel("label2").setRank(7).build(), // 1 3 - new ComponentName("package2", "class2"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut3") - .setIntent(testIntent).setShortLabel("label3").setRank(1).build(), // 2 0 - new ComponentName("package3", "class3"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut4") - .setIntent(testIntent).setShortLabel("label4").setRank(3).build(), // 3 2 - new ComponentName("package4", "class4"))); - - return shortcuts; - } - - private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { - AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); - handles - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) - .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); - if (workAvailable) { - handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); - } - if (cloneAvailable) { - handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); - } - ChooserWrapperActivity.sOverrides.annotatedUserHandles = handles.build(); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos) { - setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos, - List<ResolvedComponentInfo> workResolvedComponentInfos) { - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when( - ChooserActivityOverrideData - .getInstance() - .workResolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when( - ChooserActivityOverrideData - .getInstance() - .workResolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.of(10)))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } - - private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { - return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount)); - } - - private static class GridRecyclerSpanCountMatcher extends - BoundedDiagnosingMatcher<View, RecyclerView> { - - private final Matcher<Integer> mIntegerMatcher; - - private GridRecyclerSpanCountMatcher(Matcher<Integer> integerMatcher) { - super(RecyclerView.class); - this.mIntegerMatcher = integerMatcher; - } - - @Override - protected void describeMoreTo(Description description) { - description.appendText("RecyclerView grid layout span count to match: "); - this.mIntegerMatcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(RecyclerView view, Description mismatchDescription) { - int spanCount = ((GridLayoutManager) view.getLayoutManager()).getSpanCount(); - if (this.mIntegerMatcher.matches(spanCount)) { - return true; - } else { - mismatchDescription.appendText("RecyclerView grid layout span count was ") - .appendValue(spanCount); - return false; - } - } - } - - private void givenAppTargets(int appCount) { - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsForTest(appCount); - setupResolverControllers(resolvedComponentInfos); - } - - private void updateMaxTargetsPerRowResource(int targetsPerRow) { - Resources resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - ChooserActivityOverrideData.getInstance().resources = resources; - doReturn(targetsPerRow).when(resources).getInteger( - R.integer.config_chooser_max_targets_per_row); - } - - 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; - } - - private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) { - return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap)); - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt b/tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt index 1cc1a6a6..90acaa60 100644 --- a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt +++ b/tests/activity/src/com/android/intentresolver/ext/RecyclerViewExt.kt @@ -14,16 +14,15 @@ * limitations under the License. */ -package com.android.intentresolver.contentpreview +@file:JvmName("RecyclerViewExt") -import android.service.chooser.ChooserAction -import com.android.intentresolver.widget.ActionRow -import kotlinx.coroutines.flow.Flow +package com.android.intentresolver.ext -interface MutableActionFactory { - /** A flow of custom actions */ - val customActionsFlow: Flow<List<ActionRow.Action>> +import androidx.recyclerview.widget.RecyclerView - /** Update custom actions */ - fun updateCustomActions(actions: List<ChooserAction>) +/** Ends active RecyclerView animations, if any */ +fun RecyclerView.endAnimations() { + if (isAnimating) { + itemAnimator?.endAnimations() + } } diff --git a/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt index cd808af4..d1dea7c3 100644 --- a/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt +++ b/tests/activity/src/com/android/intentresolver/logging/TestEventLogModule.kt @@ -21,16 +21,16 @@ import com.android.internal.logging.InstanceIdSequence import dagger.Binds import dagger.Module import dagger.Provides -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.testing.TestInstallIn /** Binds a [FakeEventLog] as [EventLog] in tests. */ @Module -@TestInstallIn(components = [ActivityComponent::class], replaces = [EventLogModule::class]) +@TestInstallIn(components = [ActivityRetainedComponent::class], replaces = [EventLogModule::class]) interface TestEventLogModule { - @Binds @ActivityScoped fun fakeEventLog(impl: FakeEventLog): EventLog + @Binds @ActivityRetainedScoped fun fakeEventLog(impl: FakeEventLog): EventLog companion object { @Provides diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java deleted file mode 100644 index d6ee706a..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (C) 2021 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.v2; - -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.res.Resources; -import android.database.Cursor; -import android.os.UserHandle; - -import com.android.intentresolver.AnnotatedUserHandles; -import com.android.intentresolver.WorkProfileAvailabilityManager; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.shortcuts.ShortcutLoader; - -import kotlin.jvm.functions.Function2; - -import java.util.function.Consumer; -import java.util.function.Function; - -/** - * 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 - * this singleton to modify behavior. - */ -public class ChooserActivityOverrideData { - private static ChooserActivityOverrideData sInstance = null; - - public static ChooserActivityOverrideData getInstance() { - if (sInstance == null) { - sInstance = new ChooserActivityOverrideData(); - } - return sInstance; - } - public Function<TargetInfo, Boolean> onSafelyStartInternalCallback; - public Function<TargetInfo, Boolean> onSafelyStartCallback; - public Function2<UserHandle, Consumer<ShortcutLoader.Result>, ShortcutLoader> - shortcutLoaderFactory = (userHandle, callback) -> null; - public ChooserListController resolverListController; - public ChooserListController workResolverListController; - public Boolean isVoiceInteraction; - public Cursor resolverCursor; - public boolean resolverForceException; - public ImageLoader imageLoader; - public int alternateProfileSetting; - public Resources resources; - public AnnotatedUserHandles annotatedUserHandles; - public boolean hasCrossProfileIntents; - public boolean isQuietModeEnabled; - public Integer myUserId; - public WorkProfileAvailabilityManager mWorkProfileAvailability; - public CrossProfileIntentsChecker mCrossProfileIntentsChecker; - - public void reset() { - onSafelyStartInternalCallback = null; - isVoiceInteraction = null; - imageLoader = null; - resolverCursor = null; - resolverForceException = false; - resolverListController = mock(ChooserListController.class); - workResolverListController = mock(ChooserListController.class); - alternateProfileSetting = 0; - resources = null; - annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) - .setPersonalProfileUserHandle(UserHandle.SYSTEM) - .build(); - hasCrossProfileIntents = true; - isQuietModeEnabled = false; - myUserId = null; - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; - shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); - - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); - when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) - .thenAnswer(invocation -> hasCrossProfileIntents); - } - - private ChooserActivityOverrideData() {} -} - diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java deleted file mode 100644 index 07e6e7b4..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright (C) 2008 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -import android.annotation.Nullable; -import android.app.prediction.AppPredictor; -import android.app.usage.UsageStatsManager; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.ResolveInfo; -import android.content.res.Resources; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.os.UserHandle; - -import androidx.lifecycle.ViewModelProvider; - -import com.android.intentresolver.ChooserListAdapter; -import com.android.intentresolver.IChooserWrapper; -import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.TestContentPreviewViewModel; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -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 - * information, see {@code com.android.internal.app.ChooserWrapperActivity}. - */ -public class ChooserWrapperActivity extends ChooserActivity implements IChooserWrapper { - static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); - private UsageStatsManager mUsm; - - @Override - protected final ChooserActivityLogic createActivityLogic() { - return new TestChooserActivityLogic( - "ChooserWrapper", - /* activity = */ this, - this::onWorkProfileStatusUpdated, - sOverrides.annotatedUserHandles, - sOverrides.mWorkProfileAvailability); - } - - @Override - public ChooserListAdapter createChooserListAdapter( - Context context, - List<Intent> payloadIntents, - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - ResolverListController resolverListController, - UserHandle userHandle, - Intent targetIntent, - Intent referrerFillInIntent, - int maxTargetsPerRow) { - - return new ChooserListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - targetIntent, - referrerFillInIntent, - this, - mPackageManager, - getEventLog(), - maxTargetsPerRow, - userHandle, - mTargetDataLoader, - null, - mFeatureFlags); - } - - @Override - public ChooserListAdapter getAdapter() { - return mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - } - - @Override - public ChooserListAdapter getPersonalListAdapter() { - return mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); - } - - @Override - public ChooserListAdapter getWorkListAdapter() { - return mChooserMultiProfilePagerAdapter.getWorkListAdapter(); - } - - @Override - public boolean getIsSelected() { - return mIsSuccessfullySelected; - } - - @Override - public UsageStatsManager getUsageStatsManager() { - if (mUsm == null) { - mUsm = getSystemService(UsageStatsManager.class); - } - return mUsm; - } - - @Override - public boolean isVoiceInteraction() { - if (sOverrides.isVoiceInteraction != null) { - return sOverrides.isVoiceInteraction; - } - return super.isVoiceInteraction(); - } - - @Override - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - if (sOverrides.mCrossProfileIntentsChecker != null) { - return sOverrides.mCrossProfileIntentsChecker; - } - return super.createCrossProfileIntentsChecker(); - } - - @Override - public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, - @Nullable Bundle options) { - if (sOverrides.onSafelyStartInternalCallback != null - && sOverrides.onSafelyStartInternalCallback.apply(cti)) { - return; - } - super.safelyStartActivityInternal(cti, user, options); - } - - @Override - protected ChooserListController createListController(UserHandle userHandle) { - if (userHandle == UserHandle.SYSTEM) { - return sOverrides.resolverListController; - } - return sOverrides.workResolverListController; - } - - @Override - public Resources getResources() { - if (sOverrides.resources != null) { - return sOverrides.resources; - } - return super.getResources(); - } - - @Override - protected ViewModelProvider.Factory createPreviewViewModelFactory() { - return TestContentPreviewViewModel.Companion.wrap( - super.createPreviewViewModelFactory(), - sOverrides.imageLoader); - } - - @Override - public Cursor queryResolver(ContentResolver resolver, Uri uri) { - if (sOverrides.resolverCursor != null) { - return sOverrides.resolverCursor; - } - - if (sOverrides.resolverForceException) { - throw new SecurityException("Test exception handling"); - } - - return super.queryResolver(resolver, uri); - } - - @Override - protected boolean isWorkProfile() { - if (sOverrides.alternateProfileSetting != 0) { - return sOverrides.alternateProfileSetting == MetricsEvent.MANAGED_PROFILE; - } - return super.isWorkProfile(); - } - - @Override - public DisplayResolveInfo createTestDisplayResolveInfo( - Intent originalIntent, - ResolveInfo pri, - CharSequence pLabel, - CharSequence pInfo, - Intent replacementIntent) { - return DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - pri, - pLabel, - pInfo, - replacementIntent); - } - - @Override - public UserHandle getCurrentUserHandle() { - return mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); - } - - @Override - public Context createContextAsUser(UserHandle user, int flags) { - // return the current context as a work profile doesn't really exist in these tests - return this; - } - - @Override - 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; - } - return super.createShortcutLoader( - context, appPredictor, userHandle, targetIntentFilter, callback); - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java deleted file mode 100644 index 993f1760..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java +++ /dev/null @@ -1,1115 +0,0 @@ -/* - * 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.v2; - -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.v2.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.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.fail; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -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.AnnotatedUserHandles; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolvedComponentInfo; -import com.android.intentresolver.ResolverDataProvider; -import com.android.intentresolver.widget.ResolverDrawerLayout; - -import com.google.android.collect.Lists; - -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - -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) -@HiltAndroidTest -public class ResolverActivityTest { - - private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app - .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser(); - private static final UserHandle WORK_PROFILE_USER_HANDLE = UserHandle.of(10); - private static final UserHandle CLONE_PROFILE_USER_HANDLE = UserHandle.of(11); - - protected Intent getConcreteIntentForLaunch(Intent clientIntent) { - clientIntent.setClass( - androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(), - ResolverWrapperActivity.class); - return clientIntent; - } - - @Rule(order = 0) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - - @Rule(order = 1) - 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, - PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - onView(withId(com.android.internal.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, - PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - waitForIdle(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); - final int initialResolverHeight = viewPager.getHeight(); - - activity.runOnUiThread(() -> { - ResolverDrawerLayout layout = (ResolverDrawerLayout) - activity.findViewById( - com.android.internal.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( - com.android.internal.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, - PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - waitForIdle(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); - final View divider = activity.findViewById(com.android.internal.R.id.divider); - final RelativeLayout profileView = - (RelativeLayout) activity.findViewById(com.android.internal.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( - com.android.internal.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, - PERSONAL_USER_HANDLE); - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - - setupResolverControllers(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.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - onView(withId(com.android.internal.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, - PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - - ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); - Intent sendIntent = createSendImageIntent(); - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().getCount(), is(1)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - // Make a stable copy of the components as the original list may be modified - List<ResolvedComponentInfo> stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10, - PERSONAL_USER_HANDLE); - // 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(com.android.internal.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, PERSONAL_USER_HANDLE); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - // Confirm that the button bar is disabled by default - onView(withId(com.android.internal.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, PERSONAL_USER_HANDLE); - - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - onView(withId(com.android.internal.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, PERSONAL_USER_HANDLE); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - // Confirm that the button bar is disabled by default - onView(withId(com.android.internal.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, PERSONAL_USER_HANDLE); - - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)).perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - public void testWorkTab_displayedWhenWorkProfileUserAvailable() { - Intent sendIntent = createSendImageIntent(); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - onView(withId(com.android.internal.R.id.tabs)).check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { - Intent sendIntent = createSendImageIntent(); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - onView(withId(com.android.internal.R.id.tabs)).check(matches(not(isDisplayed()))); - } - - @Test - public void testWorkTab_workTabListPopulatedBeforeGoingToTab() throws InterruptedException { - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10, - PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, - new ArrayList<>(workResolvedComponentInfos)); - Intent sendIntent = createSendImageIntent(); - - 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, - PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - 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, PERSONAL_USER_HANDLE); - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - 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 { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - 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 { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.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(com.android.internal.R.id.button_once)) - .perform(click()); - - waitForIdle(); - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets() - throws InterruptedException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - 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() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createOpenWebsiteIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - TextView headerText = activity.findViewById(com.android.internal.R.id.title); - String initialText = headerText.getText().toString(); - assertFalse("Header text is empty.", initialText.isEmpty()); - assertThat(headerText.getVisibility(), is(View.VISIBLE)); - } - - @Test - public void testWorkTab_switchTabs_headerStaysSame() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createOpenWebsiteIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - TextView headerText = activity.findViewById(com.android.internal.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 { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.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(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); - 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(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_workProfileDisabled_emptyStateShown() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); - sOverrides.isQuietModeEnabled = true; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.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() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.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() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; - sOverrides.hasCrossProfileIntents = false; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.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() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(1, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); - // 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(com.android.internal.R.id.open_cross_profile)).check(matches(isDisplayed())); - } - - @Test - public void testMiniResolver_noCurrentProfileTarget() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(0, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(1, WORK_PROFILE_USER_HANDLE); - 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(com.android.internal.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() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0, WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.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_doesNotAutoLaunch() { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_PROFILE_USER_HANDLE); - sOverrides.hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertNull(chosen[0]); - } - - @Test - public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException { - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); - - // 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, PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.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)); - } - - @Test - public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_layoutWithDefault_neverShown() throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 2, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - assertThat(activity.getAdapter().hasFilteredItem(), is(false)); - assertThat(activity.getAdapter().getCount(), is(2)); - assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); - } - - @Test - public void testClonedProfilePresent_alwaysButtonDisabled() throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - // Confirm that the button bar is disabled by default - onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); - onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); - - // Make a stable copy of the components as the original list may be modified - List<ResolvedComponentInfo> stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); - - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - - onView(withId(com.android.internal.R.id.button_once)).check(matches(isEnabled())); - onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); - } - - @Test - public void testClonedProfilePresent_personalProfileActivityIsStartedInCorrectUser() - throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); - - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); - sOverrides.hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - selectedActivityUserHandle[0] = result.second; - return true; - }; - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(first(allOf(withText(personalResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - - assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); - } - - @Test - public void testClonedProfilePresent_workProfileActivityIsStartedInCorrectUser() - throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ true); - - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3, WORK_PROFILE_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - selectedActivityUserHandle[0] = result.second; - return true; - }; - - final ResolverWrapperActivity activity = 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(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - - assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); - } - - @Test - public void testClonedProfilePresent_personalProfileResolverComparatorHasCorrectUsers() - throws Exception { - // enable cloneProfile - markOtherProfileAvailability(/* workAvailable= */ false, /* cloneAvailable= */ true); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - CLONE_PROFILE_USER_HANDLE); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - List<UserHandle> result = activity - .getResolverRankerServiceUserHandleList(PERSONAL_USER_HANDLE); - - assertThat(result.containsAll( - Lists.newArrayList(PERSONAL_USER_HANDLE, CLONE_PROFILE_USER_HANDLE)), is(true)); - } - - 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, - UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsWithCloneProfileForTest( - int numberOfResults, - UserHandle resolvedForPersonalUser, - UserHandle resolvedForClonedUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < 1; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForPersonalUser)); - } - for (int i = 1; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForClonedUser)); - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, - UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, - resolvedForUser)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId, UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add( - ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - resolvedForUser)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - } - return infoList; - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private void markOtherProfileAvailability(boolean workAvailable, boolean cloneAvailable) { - AnnotatedUserHandles.Builder handles = AnnotatedUserHandles.newBuilder(); - handles - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(PERSONAL_USER_HANDLE) - .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE); - if (workAvailable) { - handles.setWorkProfileUserHandle(WORK_PROFILE_USER_HANDLE); - } - if (cloneAvailable) { - handles.setCloneProfileUserHandle(CLONE_PROFILE_USER_HANDLE); - } - sOverrides.annotatedUserHandles = handles.build(); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos) { - setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos, - List<ResolvedComponentInfo> workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.of(10)))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java deleted file mode 100644 index 2e29be11..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ /dev/null @@ -1,272 +0,0 @@ -/* - * 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.v2; - -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.annotation.Nullable; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.os.UserHandle; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.test.espresso.idling.CountingIdlingResource; - -import com.android.intentresolver.AnnotatedUserHandles; -import com.android.intentresolver.ResolverListAdapter; -import com.android.intentresolver.ResolverListController; -import com.android.intentresolver.WorkProfileAvailabilityManager; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; -import com.android.intentresolver.icons.LabelInfo; -import com.android.intentresolver.icons.TargetDataLoader; - -import kotlin.Unit; - -import java.util.List; -import java.util.function.Consumer; -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 final CountingIdlingResource mLabelIdlingResource = - new CountingIdlingResource("LoadLabelTask"); - - @Override - protected final ResolverActivityLogic createActivityLogic() { - return new TestResolverActivityLogic( - "ResolverWrapper", - this, - () -> { - onWorkProfileStatusUpdated(); - return Unit.INSTANCE; - }, - sOverrides - ); - } - - public CountingIdlingResource getLabelIdlingResource() { - return mLabelIdlingResource; - } - - @Override - public ResolverListAdapter createResolverListAdapter( - Context context, - List<Intent> payloadIntents, - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - UserHandle userHandle) { - return new ResolverListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - payloadIntents.get(0), // TODO: extract upstream - this, - userHandle, - new TargetDataLoaderWrapper(mTargetDataLoader, mLabelIdlingResource)); - } - - @Override - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - if (sOverrides.mCrossProfileIntentsChecker != null) { - return sOverrides.mCrossProfileIntentsChecker; - } - return super.createCrossProfileIntentsChecker(); - } - - ResolverListAdapter getAdapter() { - return mMultiProfilePagerAdapter.getActiveListAdapter(); - } - - ResolverListAdapter getPersonalListAdapter() { - return mMultiProfilePagerAdapter.getPersonalListAdapter(); - } - - ResolverListAdapter getWorkListAdapter() { - return mMultiProfilePagerAdapter.getWorkListAdapter(); - } - - @Override - public boolean isVoiceInteraction() { - if (sOverrides.isVoiceInteraction != null) { - return sOverrides.isVoiceInteraction; - } - return super.isVoiceInteraction(); - } - - @Override - public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, - @Nullable Bundle options) { - if (sOverrides.onSafelyStartInternalCallback != null - && sOverrides.onSafelyStartInternalCallback.apply(new Pair<>(cti, user))) { - return; - } - super.safelyStartActivityInternal(cti, user, options); - } - - @Override - protected ResolverListController createListController(UserHandle userHandle) { - if (userHandle == UserHandle.SYSTEM) { - return sOverrides.resolverListController; - } - return sOverrides.workResolverListController; - } - - protected UserHandle getCurrentUserHandle() { - return mMultiProfilePagerAdapter.getCurrentUserHandle(); - } - - @Override - public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { - super.startActivityAsUser(intent, options, user); - } - - @Override - protected List<UserHandle> getResolverRankerServiceUserHandleListInternal(UserHandle - userHandle) { - return super.getResolverRankerServiceUserHandleListInternal(userHandle); - } - - /** - * We cannot directly mock the activity created since instrumentation creates it. - * <p> - * Instead, we use static instances of this object to modify behavior. - */ - public static class OverrideData { - @SuppressWarnings("Since15") - public Function<PackageManager, PackageManager> createPackageManager; - public Function<Pair<TargetInfo, UserHandle>, Boolean> onSafelyStartInternalCallback; - public ResolverListController resolverListController; - public ResolverListController workResolverListController; - public Boolean isVoiceInteraction; - public AnnotatedUserHandles annotatedUserHandles; - public Integer myUserId; - public boolean hasCrossProfileIntents; - public boolean isQuietModeEnabled; - public WorkProfileAvailabilityManager mWorkProfileAvailability; - public CrossProfileIntentsChecker mCrossProfileIntentsChecker; - - public void reset() { - onSafelyStartInternalCallback = null; - isVoiceInteraction = null; - createPackageManager = null; - resolverListController = mock(ResolverListController.class); - workResolverListController = mock(ResolverListController.class); - annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(UserHandle.SYSTEM) - .setPersonalProfileUserHandle(UserHandle.SYSTEM) - .build(); - myUserId = null; - hasCrossProfileIntents = true; - isQuietModeEnabled = false; - - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; - - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); - when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) - .thenAnswer(invocation -> hasCrossProfileIntents); - } - } - - private static class TargetDataLoaderWrapper extends TargetDataLoader { - private final TargetDataLoader mTargetDataLoader; - private final CountingIdlingResource mLabelIdlingResource; - - private TargetDataLoaderWrapper( - TargetDataLoader targetDataLoader, CountingIdlingResource labelIdlingResource) { - mTargetDataLoader = targetDataLoader; - mLabelIdlingResource = labelIdlingResource; - } - - @Override - public void loadAppTargetIcon( - @NonNull DisplayResolveInfo info, - @NonNull UserHandle userHandle, - @NonNull Consumer<Drawable> callback) { - mTargetDataLoader.loadAppTargetIcon(info, userHandle, callback); - } - - @Override - public void loadDirectShareIcon( - @NonNull SelectableTargetInfo info, - @NonNull UserHandle userHandle, - @NonNull Consumer<Drawable> callback) { - mTargetDataLoader.loadDirectShareIcon(info, userHandle, callback); - } - - @Override - public void loadLabel( - @NonNull DisplayResolveInfo info, - @NonNull Consumer<LabelInfo> callback) { - mLabelIdlingResource.increment(); - mTargetDataLoader.loadLabel( - info, - (result) -> { - mLabelIdlingResource.decrement(); - callback.accept(result); - }); - } - - @Override - public void getOrLoadLabel(@NonNull DisplayResolveInfo info) { - mTargetDataLoader.getOrLoadLabel(info); - } - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt deleted file mode 100644 index fe649819..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.android.intentresolver.v2 - -import androidx.activity.ComponentActivity -import com.android.intentresolver.AnnotatedUserHandles -import com.android.intentresolver.WorkProfileAvailabilityManager - -/** Activity logic for use when testing [ChooserActivity]. */ -class TestChooserActivityLogic( - tag: String, - activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, - private val annotatedUserHandlesOverride: AnnotatedUserHandles?, - private val workProfileAvailabilityOverride: WorkProfileAvailabilityManager?, -) : - ChooserActivityLogic( - tag, - activity, - onWorkProfileStatusUpdated, - ) { - override val annotatedUserHandles: AnnotatedUserHandles? - get() = annotatedUserHandlesOverride ?: super.annotatedUserHandles - - override val workProfileAvailabilityManager: WorkProfileAvailabilityManager - get() = workProfileAvailabilityOverride ?: super.workProfileAvailabilityManager -} diff --git a/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt deleted file mode 100644 index 6826f23d..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.android.intentresolver.v2 - -import androidx.activity.ComponentActivity -import com.android.intentresolver.AnnotatedUserHandles -import com.android.intentresolver.WorkProfileAvailabilityManager - -/** Activity logic for use when testing [ResolverActivity]. */ -class TestResolverActivityLogic( - tag: String, - activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit, - private val overrideData: ResolverWrapperActivity.OverrideData, -) : ResolverActivityLogic(tag, activity, onWorkProfileStatusUpdated) { - - override val annotatedUserHandles: AnnotatedUserHandles? by lazy { - overrideData.annotatedUserHandles - } - - override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy { - overrideData.mWorkProfileAvailability ?: super.workProfileAvailabilityManager - } -} diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java deleted file mode 100644 index e4ec1776..00000000 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityWorkProfileTest.java +++ /dev/null @@ -1,481 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2; - -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.v2.ChooserWrapperActivity.sOverrides; -import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; -import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; -import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; -import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; -import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; -import static com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; -import static com.android.intentresolver.v2.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.AnnotatedUserHandles; -import com.android.intentresolver.R; -import com.android.intentresolver.ResolvedComponentInfo; -import com.android.intentresolver.ResolverDataProvider; -import com.android.intentresolver.v2.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; - -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; - -import dagger.hilt.android.testing.HiltAndroidRule; -import dagger.hilt.android.testing.HiltAndroidTest; - -@DeviceFilter.MediumType -@RunWith(Parameterized.class) -@HiltAndroidTest -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(order = 0) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); - - @Rule(order = 1) - 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(); - - 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, UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add( - ResolverDataProvider - .createResolvedComponentInfoWithOtherId(i, userId, resolvedForUser)); - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults, - UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - return infoList; - } - - private void setUpPersonalAndWorkComponentInfos() { - ChooserWrapperActivity.sOverrides.annotatedUserHandles = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(1234) // Must be non-negative. - .setUserHandleSharesheetLaunchedAs(mTestCase.getMyUserHandle()) - .setPersonalProfileUserHandle(PERSONAL_USER_HANDLE) - .setWorkProfileUserHandle(WORK_USER_HANDLE) - .build(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, - /* userId */ WORK_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos, - List<ResolvedComponentInfo> workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(WORK_USER_HANDLE))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - 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(com.android.internal.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/tests/integration/Android.bp b/tests/integration/Android.bp index f17df160..4c8fc37a 100644 --- a/tests/integration/Android.bp +++ b/tests/integration/Android.bp @@ -15,6 +15,7 @@ // package { + default_team: "trendy_team_capture_and_share", default_applicable_licenses: ["Android-Apache-2.0"], } @@ -40,5 +41,5 @@ android_test { "truth", "truth-java8-extension", ], - test_suites: ["general-tests"] + test_suites: ["general-tests"], } diff --git a/tests/integration/src/com/android/intentresolver/v2/data/repository/PlaceholderTest.kt b/tests/integration/src/com/android/intentresolver/v2/data/repository/PlaceholderTest.kt index b66a1906..af2836aa 100644 --- a/tests/integration/src/com/android/intentresolver/v2/data/repository/PlaceholderTest.kt +++ b/tests/integration/src/com/android/intentresolver/v2/data/repository/PlaceholderTest.kt @@ -21,7 +21,5 @@ import org.junit.Test class PlaceholderTest { /** Allows this test target to function while tests are being developed. */ - @Test - fun placeHolder() { - } + @Test fun placeHolder() {} } diff --git a/tests/shared/Android.bp b/tests/shared/Android.bp index 55188ee3..041e1ccc 100644 --- a/tests/shared/Android.bp +++ b/tests/shared/Android.bp @@ -31,7 +31,9 @@ java_library { static_libs: [ "hamcrest", "IntentResolver-core", + "kosmos", + "mockito-kotlin-nodeps", "mockito-target-minus-junit4", - "truth" + "truth", ], } diff --git a/tests/shared/src/com/android/intentresolver/CoroutinesKosmos.kt b/tests/shared/src/com/android/intentresolver/CoroutinesKosmos.kt new file mode 100644 index 00000000..eacefdc0 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/CoroutinesKosmos.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import com.android.systemui.kosmos.Kosmos +import kotlinx.coroutines.CoroutineDispatcher + +var Kosmos.backgroundDispatcher: CoroutineDispatcher by Kosmos.Fixture() diff --git a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt index f0203bb6..c57ea78b 100644 --- a/tests/shared/src/com/android/intentresolver/TestPreviewImageLoader.kt +++ b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt @@ -22,7 +22,9 @@ import com.android.intentresolver.contentpreview.ImageLoader import java.util.function.Consumer import kotlinx.coroutines.CoroutineScope -class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoader { +class FakeImageLoader(initialBitmaps: Map<Uri, Bitmap> = emptyMap()) : ImageLoader { + private val bitmaps = HashMap<Uri, Bitmap>().apply { putAll(initialBitmaps) } + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { callback.accept(bitmaps[uri]) } @@ -30,4 +32,8 @@ class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoade override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri] override fun prePopulate(uris: List<Uri>) = Unit + + fun setBitmap(uri: Uri, bitmap: Bitmap) { + bitmaps[uri] = bitmap + } } diff --git a/tests/shared/src/com/android/intentresolver/FrameworkMocksKosmos.kt b/tests/shared/src/com/android/intentresolver/FrameworkMocksKosmos.kt new file mode 100644 index 00000000..df3931c6 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/FrameworkMocksKosmos.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.content.ContentResolver +import android.content.pm.PackageManager +import com.android.systemui.kosmos.Kosmos + +var Kosmos.contentResolver by Kosmos.Fixture { org.mockito.kotlin.mock<ContentResolver> {} } +var Kosmos.contentInterface by Kosmos.Fixture { contentResolver } +var Kosmos.packageManager by Kosmos.Fixture { org.mockito.kotlin.mock<PackageManager> {} } diff --git a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt index db9fbd93..755262ee 100644 --- a/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt +++ b/tests/shared/src/com/android/intentresolver/MockitoKotlinHelpers.kt @@ -14,15 +14,12 @@ * limitations under the License. */ +@file:Suppress("NOTHING_TO_INLINE") + 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 kotlin.DeprecationLevel.ERROR +import kotlin.DeprecationLevel.WARNING import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatcher import org.mockito.ArgumentMatchers @@ -32,49 +29,112 @@ import org.mockito.stubbing.Answer import org.mockito.stubbing.OngoingStubbing import org.mockito.stubbing.Stubber +/* + * 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 + */ + /** - * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. + * 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) +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "eq", imports = ["org.mockito.kotlin.eq"]), + level = ERROR +) +inline fun <T> eq(obj: T): T = Mockito.eq<T>(obj) ?: obj /** - * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. + * Returns Mockito.same() 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) +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "same(obj)", imports = ["org.mockito.kotlin.same"]), + level = ERROR +) +inline fun <T> same(obj: T): T = Mockito.same<T>(obj) ?: 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?. + */ +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "any(type)", imports = ["org.mockito.kotlin.any"]), + level = WARNING +) +inline fun <T> any(type: Class<T>): T = Mockito.any<T>(type) + +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "any()", imports = ["org.mockito.kotlin.any"]), + level = WARNING +) 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. + * 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) +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "argThat(matcher)", imports = ["org.mockito.kotlin.argThat"]), + level = WARNING +) +inline fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher) /** * Kotlin type-inferred version of Mockito.nullable() + * + * @see org.mockito.kotlin.anyOrNull */ +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "anyOrNull()", imports = ["org.mockito.kotlin.anyOrNull"]), + level = ERROR +) 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. + * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. * * Generic T is nullable because implicitly bounded by Any?. + * + * @see org.mockito.kotlin.capture */ -fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture() +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "capture(argumentCaptor)", imports = ["org.mockito.kotlin.capture"]), + level = ERROR +) +inline 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?. + * + * @see org.mockito.kotlin.argumentCaptor */ +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "argumentCaptor()", imports = ["org.mockito.kotlin.argumentCaptor"]), + level = ERROR +) inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> = ArgumentCaptor.forClass(T::class.java) @@ -83,24 +143,69 @@ inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> = * * Generic T is nullable because implicitly bounded by Any?. * - * @param apply builder function to simplify stub configuration by improving type inference. + * Updated kotlin-mockito usage: + * ``` + * val value: Widget = mock<> { + * on { status } doReturn "OK" + * on { buttonPress } doNothing + * on { destroy } doAnswer error("Boom!") + * } + * ``` + * + * __Deprecation note__ + * + * Automatic replacement is not possible due to a change in lambda receiver type to KStubbing<T> + * + * @see org.mockito.kotlin.mock + * @see org.mockito.kotlin.KStubbing.on */ +@Suppress("DeprecatedCallableAddReplaceWith") +@Deprecated("Replace with mockito-kotlin. See http://go/mockito-kotlin", level = WARNING) inline fun <reified T : Any> mock( mockSettings: MockSettings = Mockito.withSettings(), apply: T.() -> Unit = {} ): T = Mockito.mock(T::class.java, mockSettings).apply(apply) +/** Matches any array of type T. */ +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "anyArray()", imports = ["org.mockito.kotlin.anyArray"]), + level = ERROR +) +inline fun <reified T : Any?> anyArray(): Array<T> = Mockito.any(Array<T>::class.java) ?: arrayOf() + /** * Helper function for stubbing methods without the need to use backticks. * - * @see Mockito.when + * Avoid. It is preferable to provide stubbing at creation time using the [mock] lambda argument. + * + * @see org.mockito.kotlin.whenever */ -fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall) +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "whenever(methodCall)", imports = ["org.mockito.kotlin.whenever"]), + level = ERROR +) +inline fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall) /** * Helper function for stubbing methods without the need to use backticks. + * + * Avoid. It is preferable to provide stubbing at creation time using the [mock] lambda argument. + * + * __Deprecation note__ + * + * Replace with KStubber<T>.on within [org.mockito.kotlin.mock] { stubbing } + * + * @see org.mockito.kotlin.mock + * @see org.mockito.kotlin.KStubbing.on */ -fun <T> Stubber.whenever(mock: T): T = `when`(mock) +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "whenever(mock)", imports = ["org.mockito.kotlin.whenever"]), + level = ERROR +) +inline fun <T> Stubber.whenever(mock: T): T = `when`(mock) /** * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when @@ -108,6 +213,7 @@ fun <T> Stubber.whenever(mock: T): T = `when`(mock) * * java.lang.NullPointerException: capture() must not be null */ +@Deprecated("Replace with mockito-kotlin. See http://go/mockito-kotlin", level = WARNING) class KotlinArgumentCaptor<T> constructor(clazz: Class<T>) { private val wrapped: ArgumentCaptor<T> = ArgumentCaptor.forClass(clazz) fun capture(): T = wrapped.capture() @@ -121,57 +227,67 @@ class KotlinArgumentCaptor<T> constructor(clazz: Class<T>) { * Helper function for creating an argumentCaptor in kotlin. * * Generic T is nullable because implicitly bounded by Any?. + * + * @see org.mockito.kotlin.argumentCaptor */ +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "argumentCaptor()", imports = ["org.mockito.kotlin.argumentCaptor"]), + level = WARNING +) 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 + * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured = + * captor.value * * becomes: * - * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) } + * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) } * * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. + * + * @see org.mockito.kotlin.verify */ +@Suppress("DeprecatedCallableAddReplaceWith") +@Deprecated("Replace with mockito-kotlin. See http://go/mockito-kotlin", level = WARNING) 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 + * val captor = argumentCaptor<Foo>() verify(...).someMethod(captor.capture()) val captured: + * List<Foo> = captor.allValues * * becomes: * - * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) } + * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) } + * + * @see org.mockito.kotlin.verify */ +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "capture()", imports = ["org.mockito.kotlin.capture"]), + level = WARNING +) inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> = kotlinArgumentCaptor<T>().apply { block() }.allValues +/** @see org.mockito.kotlin.anyOrNull */ +@Deprecated( + "Replace with mockito-kotlin. See http://go/mockito-kotlin", + ReplaceWith(expression = "anyOrNull()", imports = ["org.mockito.kotlin.anyOrNull"]), + level = ERROR +) inline fun <reified T> anyOrNull() = ArgumentMatchers.argThat(ArgumentMatcher<T?> { true }) /** - * Intended as a default Answer for a mock to prevent dependence on defaults. - * - * Use as: - * ``` - * val context = mock<Context>(withSettings() - * .defaultAnswer(THROWS_EXCEPTION)) - * ``` - * - * To avoid triggering the exception during stubbing, must ONLY use one of the doXXX() methods, such - * as: - * * [doAnswer][Mockito.doAnswer] - * * [doCallRealMethod][Mockito.doCallRealMethod] - * * [doNothing][Mockito.doNothing] - * * [doReturn][Mockito.doReturn] - * * [doThrow][Mockito.doThrow] + * @see org.mockito.kotlin.mock + * @see org.mockito.kotlin.doThrow */ +@Deprecated("Replace with mockito-kotlin. See http://go/mockito-kotlin", level = ERROR) val THROWS_EXCEPTION = Answer { error("Unstubbed behavior was accessed.") } diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt index b352f360..8f246424 100644 --- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -23,7 +23,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.CreationExtras import com.android.intentresolver.contentpreview.BasePreviewViewModel import com.android.intentresolver.contentpreview.ImageLoader -import com.android.intentresolver.contentpreview.PayloadToggleInteractor /** A test content preview model that supports image loader override. */ class TestContentPreviewViewModel( @@ -34,23 +33,12 @@ class TestContentPreviewViewModel( override val previewDataProvider get() = viewModel.previewDataProvider - override val payloadToggleInteractor: PayloadToggleInteractor? - get() = viewModel.payloadToggleInteractor - override fun init( targetIntent: Intent, - chooserIntent: Intent, additionalContentUri: Uri?, - focusedItemIdx: Int, isPayloadTogglingEnabled: Boolean, ) { - viewModel.init( - targetIntent, - chooserIntent, - additionalContentUri, - focusedItemIdx, - isPayloadTogglingEnabled - ) + viewModel.init(targetIntent, additionalContentUri, isPayloadTogglingEnabled) } companion object { diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt new file mode 100644 index 00000000..d3fdf17d --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri + +/** Fake implementation of [ThumbnailLoader] for use in testing. */ +class FakeThumbnailLoader : ThumbnailLoader { + + val fakeInvoke = mutableMapOf<Uri, suspend () -> Bitmap?>() + val invokeCalls = mutableListOf<Uri>() + var unfinishedInvokeCount = 0 + + override suspend fun invoke(uri: Uri): Bitmap? { + invokeCalls.add(uri) + unfinishedInvokeCount++ + val result = fakeInvoke[uri]?.invoke() + unfinishedInvokeCount-- + return result + } +} diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/MimetypeClassifierKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/MimetypeClassifierKosmos.kt new file mode 100644 index 00000000..4f979f54 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/MimetypeClassifierKosmos.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import com.android.systemui.kosmos.Kosmos + +var Kosmos.mimetypeClassifier: MimeTypeClassifier by Kosmos.Fixture { DefaultMimeTypeClassifier } diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/UriMetadataReaderKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/UriMetadataReaderKosmos.kt new file mode 100644 index 00000000..bdee477d --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/UriMetadataReaderKosmos.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import com.android.intentresolver.contentInterface +import com.android.systemui.kosmos.Kosmos + +var Kosmos.uriMetadataReader: UriMetadataReader by Kosmos.Fixture { uriMetadataReaderImpl } +val Kosmos.uriMetadataReaderImpl + get() = + UriMetadataReaderImpl( + contentInterface, + mimetypeClassifier, + ) diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PayloadToggleRepoKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PayloadToggleRepoKosmos.kt new file mode 100644 index 00000000..894ef163 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PayloadToggleRepoKosmos.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.Kosmos.Fixture + +val Kosmos.activityResultRepository by Fixture { ActivityResultRepository() } +val Kosmos.cursorPreviewsRepository by Fixture { CursorPreviewsRepository() } +val Kosmos.pendingSelectionCallbackRepository by Fixture { PendingSelectionCallbackRepository() } +val Kosmos.previewSelectionsRepository by Fixture { PreviewSelectionsRepository() } diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt new file mode 100644 index 00000000..d53210bd --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolverKosmos.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor + +import com.android.intentresolver.contentResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.inject.additionalContentUri +import com.android.intentresolver.inject.chooserIntent +import com.android.systemui.kosmos.Kosmos + +var Kosmos.payloadToggleCursorResolver: CursorResolver<CursorRow?> by + Kosmos.Fixture { payloadToggleCursorResolverImpl } +val Kosmos.payloadToggleCursorResolverImpl + get() = + PayloadToggleCursorResolver( + contentResolver, + additionalContentUri, + chooserIntent, + ) diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSenderKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSenderKosmos.kt new file mode 100644 index 00000000..1b4c0c8f --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSenderKosmos.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import com.android.systemui.kosmos.Kosmos +import org.mockito.kotlin.mock + +var Kosmos.pendingIntentSender by Kosmos.Fixture { mock<PendingIntentSender> {} } diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierKosmos.kt new file mode 100644 index 00000000..29e11a15 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierKosmos.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.systemui.kosmos.Kosmos + +var Kosmos.targetIntentModifier: TargetIntentModifier<PreviewModel> by Kosmos.Fixture() diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt new file mode 100644 index 00000000..8f7c59de --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.backgroundDispatcher +import com.android.intentresolver.contentResolver +import com.android.intentresolver.contentpreview.HeadlineGenerator +import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.mimetypeClassifier +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.activityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.pendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.payloadToggleCursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.pendingIntentSender +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.selectionChangeCallback +import com.android.intentresolver.contentpreview.uriMetadataReader +import com.android.intentresolver.data.repository.chooserRequestRepository +import com.android.intentresolver.inject.contentUris +import com.android.intentresolver.logging.eventLog +import com.android.intentresolver.packageManager +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.Kosmos.Fixture + +var Kosmos.focusedItemIndex: Int by Fixture { 0 } +var Kosmos.pageSize: Int by Fixture { 16 } +var Kosmos.maxLoadedPages: Int by Fixture { 3 } + +val Kosmos.chooserRequestInteractor + get() = ChooserRequestInteractor(chooserRequestRepository) + +val Kosmos.cursorPreviewsInteractor + get() = + CursorPreviewsInteractor( + interactor = setCursorPreviewsInteractor, + focusedItemIdx = focusedItemIndex, + uriMetadataReader = uriMetadataReader, + pageSize = pageSize, + maxLoadedPages = maxLoadedPages, + ) + +val Kosmos.customActionsInteractor + get() = + CustomActionsInteractor( + activityResultRepo = activityResultRepository, + bgDispatcher = backgroundDispatcher, + contentResolver = contentResolver, + eventLog = eventLog, + packageManager = packageManager, + chooserRequestInteractor = chooserRequestInteractor, + ) + +val Kosmos.fetchPreviewsInteractor + get() = + FetchPreviewsInteractor( + setCursorPreviews = setCursorPreviewsInteractor, + selectionRepository = previewSelectionsRepository, + cursorInteractor = cursorPreviewsInteractor, + focusedItemIdx = focusedItemIndex, + selectedItems = contentUris, + uriMetadataReader = uriMetadataReader, + cursorResolver = payloadToggleCursorResolver, + ) + +val Kosmos.processTargetIntentUpdatesInteractor + get() = + ProcessTargetIntentUpdatesInteractor( + selectionCallback = selectionChangeCallback, + repository = pendingSelectionCallbackRepository, + chooserRequestInteractor = updateChooserRequestInteractor, + ) + +val Kosmos.selectablePreviewsInteractor + get() = + SelectablePreviewsInteractor( + previewsRepo = cursorPreviewsRepository, + selectionInteractor = selectionInteractor, + ) + +val Kosmos.selectionInteractor + get() = + SelectionInteractor( + selectionsRepo = previewSelectionsRepository, + targetIntentModifier = targetIntentModifier, + updateTargetIntentInteractor = updateTargetIntentInteractor, + mimeTypeClassifier = mimetypeClassifier, + ) + +val Kosmos.setCursorPreviewsInteractor + get() = SetCursorPreviewsInteractor(previewsRepo = cursorPreviewsRepository) + +val Kosmos.updateChooserRequestInteractor + get() = + UpdateChooserRequestInteractor( + chooserRequestRepository, + pendingIntentSender, + ) + +val Kosmos.updateTargetIntentInteractor + get() = + UpdateTargetIntentInteractor( + repository = pendingSelectionCallbackRepository, + chooserRequestInteractor = updateChooserRequestInteractor, + ) + +var Kosmos.payloadToggleImageLoader: ImageLoader by Fixture() +var Kosmos.headlineGenerator: HeadlineGenerator by Fixture() diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt new file mode 100644 index 00000000..548b1f37 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.update + +import com.android.intentresolver.contentInterface +import com.android.intentresolver.inject.additionalContentUri +import com.android.intentresolver.inject.chooserIntent +import com.android.intentresolver.inject.chooserServiceFlags +import com.android.systemui.kosmos.Kosmos + +val Kosmos.selectionChangeCallbackImpl by + Kosmos.Fixture { + SelectionChangeCallbackImpl( + additionalContentUri, + chooserIntent, + contentInterface, + chooserServiceFlags, + ) + } +var Kosmos.selectionChangeCallback: SelectionChangeCallback by + Kosmos.Fixture { selectionChangeCallbackImpl } diff --git a/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt b/tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt index 73d9a084..fb8fbd3f 100644 --- a/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt +++ b/tests/shared/src/com/android/intentresolver/data/repository/FakeUserRepository.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package com.android.intentresolver.v2.data.repository +package com.android.intentresolver.data.repository -import com.android.intentresolver.v2.shared.model.User +import com.android.intentresolver.shared.model.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update diff --git a/tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.kt b/tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.kt new file mode 100644 index 00000000..0b2d3eb4 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/data/repository/V2RepositoryKosmos.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.Intent +import com.android.intentresolver.data.model.ChooserRequest +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.Kosmos.Fixture + +var Kosmos.chooserRequestRepository by Fixture { + ChooserRequestRepository( + initialRequest = ChooserRequest(targetIntent = Intent(), launchedFromPackage = "pkg"), + initialActions = emptyList() + ) +} diff --git a/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt b/tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt index 3878c39c..0b9caa32 100644 --- a/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt +++ b/tests/shared/src/com/android/intentresolver/ext/ParcelableExt.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ext +package com.android.intentresolver.ext import android.os.Parcel import android.os.Parcelable diff --git a/tests/shared/src/com/android/intentresolver/inject/ActivityModelKosmos.kt b/tests/shared/src/com/android/intentresolver/inject/ActivityModelKosmos.kt new file mode 100644 index 00000000..9944163b --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/inject/ActivityModelKosmos.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.content.Intent +import android.net.Uri +import com.android.systemui.kosmos.Kosmos + +var Kosmos.contentUris: List<Uri> by Kosmos.Fixture { emptyList() } +var Kosmos.additionalContentUri: Uri by + Kosmos.Fixture { Uri.fromParts("scheme", "ssp", "fragment") } +var Kosmos.chooserIntent: Intent by Kosmos.Fixture { Intent() } diff --git a/tests/shared/src/com/android/intentresolver/inject/ChooserServiceFlagsKosmos.kt b/tests/shared/src/com/android/intentresolver/inject/ChooserServiceFlagsKosmos.kt new file mode 100644 index 00000000..51dad82a --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/inject/ChooserServiceFlagsKosmos.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.service.chooser.FeatureFlagsImpl +import com.android.systemui.kosmos.Kosmos + +var Kosmos.chooserServiceFlags: ChooserServiceFlags by Kosmos.Fixture { chooserServiceFlagsImpl } +val chooserServiceFlagsImpl: FeatureFlagsImpl + get() = FeatureFlagsImpl() diff --git a/tests/shared/src/com/android/intentresolver/logging/EventLogKosmos.kt b/tests/shared/src/com/android/intentresolver/logging/EventLogKosmos.kt new file mode 100644 index 00000000..5bf3ddee --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/logging/EventLogKosmos.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.logging + +import com.android.internal.logging.InstanceId +import com.android.systemui.kosmos.Kosmos + +var Kosmos.eventLog by Kosmos.Fixture { fakeEventLog } +var Kosmos.fakeEventLog by Kosmos.Fixture { FakeEventLog(InstanceId.fakeInstanceId(0)) } diff --git a/tests/shared/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt b/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt index 4e279623..862be76f 100644 --- a/tests/shared/src/com/android/intentresolver/v2/platform/FakeSecureSettings.kt +++ b/tests/shared/src/com/android/intentresolver/platform/FakeSecureSettings.kt @@ -1,4 +1,20 @@ -package com.android.intentresolver.v2.platform +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform /** * Creates a SecureSettings instance with predefined values: diff --git a/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt b/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt index 370e5a00..32cb9062 100644 --- a/tests/shared/src/com/android/intentresolver/v2/platform/FakeUserManager.kt +++ b/tests/shared/src/com/android/intentresolver/platform/FakeUserManager.kt @@ -1,12 +1,22 @@ -package com.android.intentresolver.v2.platform +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform import android.content.Context -import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE -import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE -import android.content.Intent.ACTION_PROFILE_ADDED -import android.content.Intent.ACTION_PROFILE_AVAILABLE -import android.content.Intent.ACTION_PROFILE_REMOVED -import android.content.Intent.ACTION_PROFILE_UNAVAILABLE import android.content.pm.UserInfo import android.content.pm.UserInfo.FLAG_FULL import android.content.pm.UserInfo.FLAG_INITIALIZED @@ -16,19 +26,18 @@ import android.os.IUserManager import android.os.UserHandle import android.os.UserManager import androidx.annotation.NonNull -import com.android.intentresolver.THROWS_EXCEPTION -import com.android.intentresolver.mock -import com.android.intentresolver.v2.data.repository.UserRepositoryImpl.UserEvent -import com.android.intentresolver.v2.platform.FakeUserManager.State -import com.android.intentresolver.whenever +import com.android.intentresolver.data.repository.AvailabilityChange +import com.android.intentresolver.data.repository.ProfileAdded +import com.android.intentresolver.data.repository.ProfileRemoved +import com.android.intentresolver.data.repository.UserEvent +import com.android.intentresolver.platform.FakeUserManager.State import kotlin.random.Random import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.consumeAsFlow -import org.mockito.Mockito.RETURNS_SELF -import org.mockito.Mockito.doAnswer -import org.mockito.Mockito.doReturn -import org.mockito.Mockito.withSettings +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever /** * A stand-in for [UserManager] to support testing of data layer components which depend on it. @@ -155,21 +164,7 @@ class FakeUserManager(val state: State = State()) : } else { it.flags and UserInfo.FLAG_QUIET_MODE.inv() } - val actions = mutableListOf<String>() - if (quietMode) { - actions += ACTION_PROFILE_UNAVAILABLE - if (it.isManagedProfile) { - actions += ACTION_MANAGED_PROFILE_UNAVAILABLE - } - } else { - actions += ACTION_PROFILE_AVAILABLE - if (it.isManagedProfile) { - actions += ACTION_MANAGED_PROFILE_AVAILABLE - } - } - actions.forEach { action -> - eventChannel.trySend(UserEvent(action, user, quietMode)) - } + eventChannel.trySend(AvailabilityChange(user, quietMode)) } } @@ -187,7 +182,7 @@ class FakeUserManager(val state: State = State()) : profileGroupId = parentUser.profileGroupId } userInfoMap[userInfo.userHandle] = userInfo - eventChannel.trySend(UserEvent(ACTION_PROFILE_ADDED, userInfo.userHandle)) + eventChannel.trySend(ProfileAdded(userInfo.userHandle)) return userInfo.userHandle } @@ -195,7 +190,7 @@ class FakeUserManager(val state: State = State()) : return userInfoMap[handle]?.let { user -> require(user.isProfile) { "Only profiles can be removed" } userInfoMap.remove(user.userHandle) - eventChannel.trySend(UserEvent(ACTION_PROFILE_REMOVED, user.userHandle)) + eventChannel.trySend(ProfileRemoved(user.userHandle)) return true } ?: false @@ -212,11 +207,16 @@ class FakeUserManager(val state: State = State()) : } /** A safe mock of [Context] which throws on any unstubbed method call. */ -private fun mockContext(user: UserHandle = UserHandle.SYSTEM): Context { - return mock<Context>(withSettings().defaultAnswer(THROWS_EXCEPTION)) { - doAnswer(RETURNS_SELF).whenever(this).applicationContext - doReturn(user).whenever(this).user - doReturn(user.identifier).whenever(this).userId +private fun mockContext(userHandle: UserHandle = UserHandle.SYSTEM): Context { + return mock<Context>( + defaultAnswer = { + error("Unstubbed behavior invoked! (${it.method}(${it.arguments.asList()})") + } + ) { + // Careful! Specify behaviors *first* to avoid throwing while stubbing! + doReturn(mock).whenever(mock).applicationContext + doReturn(userHandle).whenever(mock).user + doReturn(userHandle.identifier).whenever(mock).userId } } @@ -230,7 +230,11 @@ private fun FakeUserManager.ProfileType.toUserType(): String { /** A safe mock of [IUserManager] which throws on any unstubbed method call. */ fun mockService(): IUserManager { - return mock<IUserManager>(withSettings().defaultAnswer(THROWS_EXCEPTION)) + return mock<IUserManager>( + defaultAnswer = { + error("Unstubbed behavior invoked! ${it.method}(${it.arguments.asList()}") + } + ) } val UserInfo.debugString: String diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp index f8b80c72..1ae8d883 100644 --- a/tests/unit/Android.bp +++ b/tests/unit/Android.bp @@ -15,6 +15,7 @@ // package { + default_team: "trendy_team_capture_and_share", default_applicable_licenses: ["Android-Apache-2.0"], } @@ -50,8 +51,10 @@ android_test { "IntentResolver-core", "IntentResolver-tests-shared", "junit", + "kosmos", "kotlinx_coroutines_test", "mockito-target-minus-junit4", + "mockito-kotlin-nodeps", "platform-compat-test-rules", // PlatformCompatChangeRule "testables", // TestableContext/TestableResources "truth", diff --git a/tests/unit/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt b/tests/unit/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt deleted file mode 100644 index cd2fbc7a..00000000 --- a/tests/unit/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.os.UserHandle - -import com.google.common.truth.Truth.assertThat - -import org.junit.Test - -class AnnotatedUserHandlesTest { - - @Test - fun testBasicProperties() { // Fields that are reflected back w/o logic. - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(116)) - .setPersonalProfileUserHandle(UserHandle.of(117)) - .setWorkProfileUserHandle(UserHandle.of(118)) - .setCloneProfileUserHandle(UserHandle.of(119)) - .build() - - assertThat(info.userIdOfCallingApp).isEqualTo(42) - assertThat(info.userHandleSharesheetLaunchedAs.identifier).isEqualTo(116) - assertThat(info.personalProfileUserHandle.identifier).isEqualTo(117) - assertThat(info.workProfileUserHandle?.identifier).isEqualTo(118) - assertThat(info.cloneProfileUserHandle?.identifier).isEqualTo(119) - } - - @Test - fun testWorkTabInitiallySelectedWhenLaunchedFromWorkProfile() { - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setPersonalProfileUserHandle(UserHandle.of(101)) - .setWorkProfileUserHandle(UserHandle.of(202)) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(202)) - .build() - - assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(202) - } - - @Test - fun testPersonalTabInitiallySelectedWhenLaunchedFromPersonalProfile() { - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setPersonalProfileUserHandle(UserHandle.of(101)) - .setWorkProfileUserHandle(UserHandle.of(202)) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(101)) - .build() - - assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101) - } - - @Test - fun testPersonalTabInitiallySelectedWhenLaunchedFromOtherProfile() { - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setPersonalProfileUserHandle(UserHandle.of(101)) - .setWorkProfileUserHandle(UserHandle.of(202)) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(303)) - .build() - - assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101) - } -} diff --git a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt index 55a94ebd..8dfbdbdd 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -29,8 +29,10 @@ import android.service.chooser.ChooserAction import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.logging.EventLog -import com.google.common.collect.ImmutableList +import com.android.intentresolver.ui.ShareResultSender +import com.android.intentresolver.ui.model.ShareAction import com.google.common.truth.Truth.assertThat +import java.util.Optional import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.function.Consumer @@ -40,15 +42,16 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify @RunWith(AndroidJUnit4::class) class ChooserActionFactoryTest { - private val context = InstrumentationRegistry.getInstrumentation().getContext() + private val context = InstrumentationRegistry.getInstrumentation().context private val logger = mock<EventLog>() private val actionLabel = "Action label" - private val modifyShareLabel = "Modify share" private val testAction = "com.android.intentresolver.testaction" private val countdown = CountDownLatch(1) private val testReceiver: BroadcastReceiver = @@ -89,27 +92,7 @@ class ChooserActionFactoryTest { // click it customActions[0].onClicked.run() - Mockito.verify(logger).logCustomActionSelected(eq(0)) - assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) - // Verify the pending intent has been called - assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) - } - - @Test - fun testNoModifyShareAction() { - val factory = createFactory(includeModifyShare = false) - - assertThat(factory.modifyShareAction).isNull() - } - - @Test - fun testModifyShareAction() { - val factory = createFactory(includeModifyShare = true) - - val action = factory.modifyShareAction ?: error("Modify share action should not be null") - action.onClicked.run() - - Mockito.verify(logger).logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE)) + verify(logger).logCustomActionSelected(eq(0)) assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) // Verify the pending intent has been called assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) @@ -122,21 +105,20 @@ class ChooserActionFactoryTest { putExtra(Intent.EXTRA_TEXT, "Text to show") } - val chooserRequest = - mock<ChooserRequestParameters> { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } val testSubject = ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - {}, + /* context = */ context, + /* targetIntent = */ targetIntent, + /* referrerPackageName = */ null, + /* chooserActions = */ emptyList(), + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ {}, + /* clipboardManager = */ mock(), ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -144,50 +126,51 @@ class ChooserActionFactoryTest { @Test fun sendActionNoText_noCopyRunnable() { val targetIntent = Intent(Intent.ACTION_SEND) - - val chooserRequest = - mock<ChooserRequestParameters> { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } val testSubject = ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - {}, + /* context = */ context, + /* targetIntent = */ targetIntent, + /* referrerPackageName = */ "com.example", + /* chooserActions = */ emptyList(), + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ {}, + /* clipboardManager = */ mock(), ) assertThat(testSubject.copyButtonRunnable).isNull() } @Test - fun sendActionWithText_nonNullCopyRunnable() { + fun sendActionWithTextCopyRunnable() { val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") } - - val chooserRequest = - mock<ChooserRequestParameters> { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } + val resultSender = mock<ShareResultSender>() val testSubject = ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - {}, + /* context = */ context, + /* targetIntent = */ targetIntent, + /* referrerPackageName = */ "com.example", + /* chooserActions = */ emptyList(), + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ resultSender, + /* finishCallback = */ {}, + /* clipboardManager = */ mock(), ) assertThat(testSubject.copyButtonRunnable).isNotNull() + + testSubject.copyButtonRunnable?.run() + + verify(resultSender) { 1 * { onActionSelected(ShareAction.SYSTEM_COPY) } } } - private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { + private fun createFactory(): ChooserActionFactory { val testPendingIntent = PendingIntent.getBroadcast(context, 0, Intent(testAction), PendingIntent.FLAG_IMMUTABLE) val targetIntent = Intent() @@ -198,30 +181,19 @@ class ChooserActionFactoryTest { testPendingIntent ) .build() - val chooserRequest = mock<ChooserRequestParameters>() - whenever(chooserRequest.targetIntent).thenReturn(targetIntent) - whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) - - if (includeModifyShare) { - val modifyShare = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - modifyShareLabel, - testPendingIntent - ) - .build() - whenever(chooserRequest.modifyShareAction).thenReturn(modifyShare) - } - return ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - resultConsumer + /* context = */ context, + /* targetIntent = */ targetIntent, + /* referrerPackageName = */ "com.example", + /* chooserActions = */ listOf(action), + /* imageEditor = */ Optional.empty(), + /* log = */ logger, + /* onUpdateSharedTextIsExcluded = */ {}, + /* firstVisibleImageQuery = */ { null }, + /* activityStarter = */ mock(), + /* shareResultSender = */ null, + /* finishCallback = */ resultConsumer, + /* clipboardManager = */ mock(), ) } } diff --git a/tests/unit/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt b/tests/unit/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt deleted file mode 100644 index 9a5dabdb..00000000 --- a/tests/unit/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.content.ComponentName -import android.provider.Settings -import android.testing.TestableContext -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChooserIntegratedDeviceComponentsTest { - private val secureSettings = mock<SecureSettings>() - private val testableContext = - TestableContext(InstrumentationRegistry.getInstrumentation().getContext()) - - @Test - fun testEditorAndNearby() { - val resources = testableContext.getOrCreateTestableResources() - - resources.addOverride(R.string.config_systemImageEditor, "") - resources.addOverride(R.string.config_defaultNearbySharingComponent, "") - - var components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) - - assertThat(components.editSharingComponent).isNull() - assertThat(components.nearbySharingComponent).isNull() - - val editor = ComponentName.unflattenFromString("com.android/com.android.Editor") - val nearby = ComponentName.unflattenFromString("com.android/com.android.nearby") - - resources.addOverride(R.string.config_systemImageEditor, editor?.flattenToString()) - resources.addOverride( - R.string.config_defaultNearbySharingComponent, nearby?.flattenToString()) - - components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) - - assertThat(components.editSharingComponent).isEqualTo(editor) - assertThat(components.nearbySharingComponent).isEqualTo(nearby) - - val anotherNearby = - ComponentName.unflattenFromString("com.android/com.android.another_nearby") - whenever( - secureSettings.getString( - any(), - eq(Settings.Secure.NEARBY_SHARING_COMPONENT) - ) - ).thenReturn(anotherNearby?.flattenToString()) - - components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) - - assertThat(components.nearbySharingComponent).isEqualTo(anotherNearby) - } -} diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt index ca91c243..df0c5e5e 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver import android.content.Context @@ -15,29 +31,33 @@ import com.android.intentresolver.util.TestExecutor import com.android.internal.logging.InstanceId import com.google.common.truth.Truth.assertThat import org.junit.Test -import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever class ChooserListAdapterDataTest { private val layoutInflater = mock<LayoutInflater>() private val packageManager = mock<PackageManager>() - private val userManager = mock<UserManager> { whenever(isManagedProfile).thenReturn(false) } + private val userManager = mock<UserManager> { on { isManagedProfile } doReturn false } private val resources = mock<android.content.res.Resources> { - whenever(getInteger(R.integer.config_maxShortcutTargetsPerApp)).thenReturn(2) + on { getInteger(R.integer.config_maxShortcutTargetsPerApp) } doReturn 2 } private val context = mock<Context> { - whenever(getSystemService(Context.LAYOUT_INFLATER_SERVICE)).thenReturn(layoutInflater) - whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) - whenever(packageManager).thenReturn(this@ChooserListAdapterDataTest.packageManager) - whenever(resources).thenReturn(this@ChooserListAdapterDataTest.resources) + on { getSystemService(Context.LAYOUT_INFLATER_SERVICE) } doReturn layoutInflater + on { getSystemService(Context.USER_SERVICE) } doReturn userManager + on { packageManager } doReturn this@ChooserListAdapterDataTest.packageManager + on { resources } doReturn this@ChooserListAdapterDataTest.resources } private val targetIntent = Intent(Intent.ACTION_SEND) private val payloadIntents = listOf(targetIntent) private val resolverListController = mock<ResolverListController> { - whenever(filterIneligibleActivities(any(), Mockito.anyBoolean())).thenReturn(null) - whenever(filterLowPriority(any(), Mockito.anyBoolean())).thenReturn(null) + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null } private val resolverListCommunicator = FakeResolverListCommunicator() private val userHandle = UserHandle.of(UserHandle.USER_CURRENT) diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt index 3c23ff26..5ac4f2b0 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -39,8 +39,11 @@ import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.times -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify @RunWith(AndroidJUnit4::class) class ChooserListAdapterTest { @@ -49,7 +52,7 @@ class ChooserListAdapterTest { private val packageManager = mock<PackageManager> { - whenever(resolveActivity(any(), any<ResolveInfoFlags>())).thenReturn(mock()) + on { resolveActivity(any(), any<ResolveInfoFlags>()) } doReturn (mock()) } private val context = InstrumentationRegistry.getInstrumentation().context private val resolverListController = mock<ResolverListController>() @@ -137,7 +140,7 @@ class ChooserListAdapterTest { testSubject.onBindView(view, targetInfo, 0) - verify(mTargetDataLoader, times(1)).loadAppTargetIcon(any(), any(), any()) + verify(mTargetDataLoader, times(1)).getOrLoadAppTargetIcon(any(), any(), any()) } @Test diff --git a/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt index 61ac0c21..f5210f71 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserRefinementManagerTest.kt @@ -29,16 +29,19 @@ import androidx.lifecycle.Observer import androidx.test.annotation.UiThreadTest import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.intentresolver.ChooserRefinementManager.RefinementCompletion +import com.android.intentresolver.ChooserRefinementManager.RefinementType import com.android.intentresolver.chooser.ImmutableTargetInfo -import com.android.intentresolver.chooser.TargetInfo import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.ArgumentCaptor -import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify @RunWith(AndroidJUnit4::class) @UiThreadTest @@ -55,15 +58,15 @@ class ChooserRefinementManagerTest { object : Observer<RefinementCompletion> { val failureCountDown = CountDownLatch(1) val successCountDown = CountDownLatch(1) - var latestTargetInfo: TargetInfo? = null + var latestRefinedIntent: Intent? = null override fun onChanged(completion: RefinementCompletion) { if (completion.consume()) { - val targetInfo = completion.targetInfo - if (targetInfo == null) { + val refinedIntent = completion.refinedIntent + if (refinedIntent == null) { failureCountDown.countDown() } else { - latestTargetInfo = targetInfo + latestRefinedIntent = refinedIntent successCountDown.countDown() } } @@ -95,28 +98,26 @@ class ChooserRefinementManagerTest { ) .isTrue() - val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) - Mockito.verify(intentSender) - .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) + val intentCaptor = argumentCaptor<Intent>() + verify(intentSender).sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) - val intent = intentCaptor.value - assertThat(intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)) + val intent = intentCaptor.firstValue + assertThat(intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)) .isEqualTo(exampleSourceIntents[0]) val alternates = - intent?.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java) + intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java) assertThat(alternates?.size).isEqualTo(1) assertThat(alternates?.get(0)).isEqualTo(exampleSourceIntents[1]) // Complete the refinement val receiver = - intent?.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java) + intent.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java) val bundle = Bundle().apply { putParcelable(Intent.EXTRA_INTENT, exampleSourceIntents[0]) } receiver?.send(Activity.RESULT_OK, bundle) assertThat(completionObserver.successCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue() - assertThat(completionObserver.latestTargetInfo?.resolvedIntent?.action) - .isEqualTo(Intent.ACTION_VIEW) + assertThat(completionObserver.latestRefinedIntent?.action).isEqualTo(Intent.ACTION_VIEW) } @Test @@ -131,11 +132,10 @@ class ChooserRefinementManagerTest { ) .isTrue() - val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) - Mockito.verify(intentSender) - .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) + val intentCaptor = argumentCaptor<Intent>() + verify(intentSender).sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) - val intent = intentCaptor.value + val intent = intentCaptor.firstValue // Complete the refinement val receiver = @@ -231,10 +231,11 @@ class ChooserRefinementManagerTest { @Test fun testRefinementCompletion() { - val refinementCompletion = RefinementCompletion(exampleTargetInfo) - assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo) + val refinementCompletion = + RefinementCompletion(RefinementType.TARGET_INFO, exampleTargetInfo, null) + assertThat(refinementCompletion.originalTargetInfo).isEqualTo(exampleTargetInfo) assertThat(refinementCompletion.consume()).isTrue() - assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo) + assertThat(refinementCompletion.originalTargetInfo).isEqualTo(exampleTargetInfo) // can only consume once. assertThat(refinementCompletion.consume()).isFalse() diff --git a/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt b/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt deleted file mode 100644 index e721b5bb..00000000 --- a/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.app.PendingIntent -import android.content.Intent -import android.graphics.drawable.Icon -import android.net.Uri -import android.service.chooser.ChooserAction -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChooserRequestParametersTest { - @Test - fun testChooserActions() { - val actionCount = 3 - val intent = Intent(Intent.ACTION_SEND) - val actions = createChooserActions(actionCount) - val chooserIntent = - Intent(Intent.ACTION_CHOOSER).apply { - putExtra(Intent.EXTRA_INTENT, intent) - putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, actions) - } - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY) - assertThat(request.chooserActions).containsExactlyElementsIn(actions).inOrder() - } - - @Test - fun testChooserActions_empty() { - val intent = Intent(Intent.ACTION_SEND) - val chooserIntent = - Intent(Intent.ACTION_CHOOSER).apply { putExtra(Intent.EXTRA_INTENT, intent) } - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY) - assertThat(request.chooserActions).isEmpty() - } - - @Test - fun testChooserActions_tooMany() { - val intent = Intent(Intent.ACTION_SEND) - val chooserActions = createChooserActions(10) - val chooserIntent = - Intent(Intent.ACTION_CHOOSER).apply { - putExtra(Intent.EXTRA_INTENT, intent) - putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, chooserActions) - } - - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY) - - val expectedActions = chooserActions.sliceArray(0 until 5) - assertThat(request.chooserActions).containsExactlyElementsIn(expectedActions).inOrder() - } - - private fun createChooserActions(count: Int): Array<ChooserAction> { - return Array(count) { i -> createChooserAction("$i") } - } - - private fun createChooserAction(label: CharSequence): ChooserAction { - val icon = Icon.createWithContentUri("content://org.package.app/image") - val pendingIntent = - PendingIntent.getBroadcast( - InstrumentationRegistry.getInstrumentation().getTargetContext(), - 0, - Intent("TESTACTION"), - PendingIntent.FLAG_IMMUTABLE - ) - return ChooserAction.Builder(icon, label, pendingIntent).build() - } -} diff --git a/tests/unit/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt b/tests/unit/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt index c7d20000..2b7d6ff9 100644 --- a/tests/unit/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt +++ b/tests/unit/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt @@ -31,10 +31,11 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Test -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify private const val TIMEOUT_MS = 200 @@ -48,18 +49,18 @@ class EnterTransitionAnimationDelegateTest { private val transitionTargetView = mock<View> { // avoid the request-layout path in the delegate - whenever(isInLayout).thenReturn(true) + on { isInLayout } doReturn true } private val windowMock = mock<Window>() private val resourcesMock = - mock<Resources> { whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS) } + mock<Resources> { on { getInteger(any<Int>()) } doReturn TIMEOUT_MS } private val activity = mock<ComponentActivity> { - whenever(lifecycle).thenReturn(lifecycleOwner.lifecycle) - whenever(resources).thenReturn(resourcesMock) - whenever(isActivityTransitionRunning).thenReturn(true) - whenever(window).thenReturn(windowMock) + on { lifecycle } doReturn lifecycleOwner.lifecycle + on { resources } doReturn resourcesMock + on { isActivityTransitionRunning } doReturn true + on { window } doReturn windowMock } private val testSubject = EnterTransitionAnimationDelegate(activity) { transitionTargetView } @@ -82,8 +83,8 @@ class EnterTransitionAnimationDelegateTest { testSubject.markOffsetCalculated() scheduler.advanceTimeBy(TIMEOUT_MS + 1L) - verify(activity, times(1)).startPostponedEnterTransition() - verify(windowMock, never()).setWindowAnimations(anyInt()) + verify(activity) { 1 * { mock.startPostponedEnterTransition() } } + verify(windowMock) { 0 * { setWindowAnimations(any<Int>()) } } } @Test @@ -101,12 +102,12 @@ class EnterTransitionAnimationDelegateTest { @Test fun test_postponeTransition_resume_animation_conditions() { testSubject.postponeTransition() - verify(activity, never()).startPostponedEnterTransition() + verify(activity) { 0 * { startPostponedEnterTransition() } } testSubject.markOffsetCalculated() - verify(activity, never()).startPostponedEnterTransition() + verify(activity) { 0 * { startPostponedEnterTransition() } } testSubject.onAllTransitionElementsReady() - verify(activity, times(1)).startPostponedEnterTransition() + verify(activity) { 1 * { startPostponedEnterTransition() } } } } diff --git a/tests/unit/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt deleted file mode 100644 index ed06f7d1..00000000 --- a/tests/unit/src/com/android/intentresolver/MultiProfilePagerAdapterTest.kt +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.os.UserHandle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ListView -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK -import com.android.intentresolver.emptystate.EmptyStateProvider -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import java.util.Optional -import java.util.function.Supplier -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.verify - -class MultiProfilePagerAdapterTest { - private val PERSONAL_USER_HANDLE = UserHandle.of(10) - private val WORK_USER_HANDLE = UserHandle.of(20) - - private val context = InstrumentationRegistry.getInstrumentation().getContext() - private val inflater = Supplier { - LayoutInflater.from(context).inflate(R.layout.resolver_list_per_profile, null, false) - as ViewGroup - } - - @Test - fun testSinglePageProfileAdapter() { - val personalListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(1) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isNull() - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isNull() - assertThat(pagerAdapter.itemCount).isEqualTo(1) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter. - } - - @Test - fun testTwoProfilePagerAdapter() { - val personalListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_PERSONAL) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(PERSONAL_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: consider exercising responsibilities as an implementation of a ViewPager adapter; - // especially matching profiles to ListViews? - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testTwoProfilePagerAdapter_workIsDefault() { - val personalListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } - val workListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : EmptyStateProvider {}, - { false }, - PROFILE_WORK, // <-- This test specifically requests we start on work profile. - WORK_USER_HANDLE, // TODO: why does this test pass even if this is null? - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.count).isEqualTo(2) - assertThat(pagerAdapter.currentPage).isEqualTo(PROFILE_WORK) - assertThat(pagerAdapter.currentUserHandle).isEqualTo(WORK_USER_HANDLE) - assertThat(pagerAdapter.getAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.getAdapterForIndex(1)).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.activeListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.inactiveListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.personalListAdapter).isSameInstanceAs(personalListAdapter) - assertThat(pagerAdapter.workListAdapter).isSameInstanceAs(workListAdapter) - assertThat(pagerAdapter.itemCount).isEqualTo(2) - // TODO: consider covering some of the package-private methods (and making them public?). - // TODO: test ProfileSelectedListener (and getters for "current" state) as the selected - // page changes. Currently there's no API to change the selected page directly; that's - // only possible through manipulation of the bound ViewPager. - } - - @Test - fun testBottomPaddingDelegate_default() { - val container = - mock<View> { - whenever(getPaddingLeft()).thenReturn(1) - whenever(getPaddingTop()).thenReturn(2) - whenever(getPaddingRight()).thenReturn(3) - whenever(getPaddingBottom()).thenReturn(4) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.empty() } - ) - pagerAdapter.setupContainerPadding(container) - verify(container, never()).setPadding(any(), any(), any(), any()) - } - - @Test - fun testBottomPaddingDelegate_override() { - val container = - mock<View> { - whenever(getPaddingLeft()).thenReturn(1) - whenever(getPaddingTop()).thenReturn(2) - whenever(getPaddingRight()).thenReturn(3) - whenever(getPaddingBottom()).thenReturn(4) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(), - object : EmptyStateProvider {}, - { false }, - PROFILE_PERSONAL, - null, - null, - inflater, - { Optional.of(42) } - ) - pagerAdapter.setupContainerPadding(container) - verify(container).setPadding(1, 2, 3, 42) - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_whenQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock<ResolverListAdapter> { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock<ResolverListAdapter> { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : EmptyStateProvider {}, - { true }, // <-- Work mode is quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isTrue() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } - - @Test - fun testPresumedQuietModeEmptyStateForWorkProfile_notWhenNotQuiet() { - // TODO: this is "presumed" because the conditions to determine whether we "should" show an - // empty state aren't enforced to align with the conditions when we actually *would* -- I - // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? - val personalListAdapter = - mock<ResolverListAdapter> { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val workListAdapter = - mock<ResolverListAdapter> { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) - } - val pagerAdapter = - MultiProfilePagerAdapter( - { listAdapter: ResolverListAdapter -> listAdapter }, - { listView: ListView, bindAdapter: ResolverListAdapter -> - listView.setAdapter(bindAdapter) - }, - ImmutableList.of(personalListAdapter, workListAdapter), - object : EmptyStateProvider {}, - { false }, // <-- Work mode is not quiet. - PROFILE_WORK, - WORK_USER_HANDLE, - null, - inflater, - { Optional.empty() } - ) - assertThat(pagerAdapter.shouldShowEmptyStateScreen(workListAdapter)).isFalse() - assertThat(pagerAdapter.shouldShowEmptyStateScreen(personalListAdapter)).isFalse() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt index b4df058c..47db0cf5 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt +++ b/tests/unit/src/com/android/intentresolver/ProfileAvailabilityTest.kt @@ -14,22 +14,21 @@ * limitations under the License. */ -package com.android.intentresolver.v2 +package com.android.intentresolver -import android.util.Log -import com.android.intentresolver.v2.data.repository.FakeUserRepository -import com.android.intentresolver.v2.domain.interactor.UserInteractor -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.shared.model.User +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.data.repository.FakeUserRepository +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.User import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -private const val TAG = "ProfileAvailabilityTest" - -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, JavaInterop::class) class ProfileAvailabilityTest { private val personalUser = User(0, User.Role.PERSONAL) private val workUser = User(10, User.Role.WORK) @@ -42,8 +41,7 @@ class ProfileAvailabilityTest { @Test fun testProfileAvailable() = runTest { - val availability = ProfileAvailability(backgroundScope, interactor) - runCurrent() + val availability = ProfileAvailability(interactor, this, Dispatchers.IO) assertThat(availability.isAvailable(personalProfile)).isTrue() assertThat(availability.isAvailable(workProfile)).isTrue() @@ -61,8 +59,7 @@ class ProfileAvailabilityTest { @Test fun waitingToEnableProfile() = runTest { - val availability = ProfileAvailability(backgroundScope, interactor) - runCurrent() + val availability = ProfileAvailability(interactor, this, Dispatchers.IO) availability.requestQuietModeState(workProfile, true) assertThat(availability.waitingToEnableProfile).isFalse() @@ -70,9 +67,8 @@ class ProfileAvailabilityTest { availability.requestQuietModeState(workProfile, false) assertThat(availability.waitingToEnableProfile).isTrue() - runCurrent() assertThat(availability.waitingToEnableProfile).isFalse() } -}
\ No newline at end of file +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt b/tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt index 9cbbfcd8..05d642f7 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt +++ b/tests/unit/src/com/android/intentresolver/ProfileHelperTest.kt @@ -14,24 +14,21 @@ * limitations under the License. */ -package com.android.intentresolver.v2 +package com.android.intentresolver import com.android.intentresolver.Flags.FLAG_ENABLE_PRIVATE_PROFILE -import com.android.intentresolver.inject.FakeChooserServiceFlags +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.data.repository.FakeUserRepository +import com.android.intentresolver.domain.interactor.UserInteractor import com.android.intentresolver.inject.FakeIntentResolverFlags -import com.android.intentresolver.inject.IntentResolverFlags -import com.android.intentresolver.v2.data.repository.FakeUserRepository -import com.android.intentresolver.v2.domain.interactor.UserInteractor -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.shared.model.User -import com.google.common.truth.Truth +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.User import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest -import org.junit.Assert.* - import org.junit.Test +@OptIn(JavaInterop::class) class ProfileHelperTest { private val personalUser = User(0, User.Role.PERSONAL) @@ -46,68 +43,69 @@ class ProfileHelperTest { private val privateUser = User(12, User.Role.PRIVATE) private val privateProfile = Profile(Profile.Type.PRIVATE, privateUser) - private val flags = FakeIntentResolverFlags().apply { - setFlag(FLAG_ENABLE_PRIVATE_PROFILE, true) - } + private val flags = + FakeIntentResolverFlags().apply { setFlag(FLAG_ENABLE_PRIVATE_PROFILE, true) } private fun assertProfiles( helper: ProfileHelper, personalProfile: Profile, workProfile: Profile? = null, - privateProfile: Profile? = null) { - + privateProfile: Profile? = null + ) { assertThat(helper.personalProfile).isEqualTo(personalProfile) assertThat(helper.personalHandle).isEqualTo(personalProfile.primary.handle) personalProfile.clone?.also { assertThat(helper.cloneUserPresent).isTrue() assertThat(helper.cloneHandle).isEqualTo(it.handle) - } ?: { - assertThat(helper.cloneUserPresent).isFalse() - assertThat(helper.cloneHandle).isNull() } + ?: { + assertThat(helper.cloneUserPresent).isFalse() + assertThat(helper.cloneHandle).isNull() + } workProfile?.also { assertThat(helper.workProfilePresent).isTrue() assertThat(helper.workProfile).isEqualTo(it) assertThat(helper.workHandle).isEqualTo(it.primary.handle) - } ?: { - assertThat(helper.workProfilePresent).isFalse() - assertThat(helper.workProfile).isNull() - assertThat(helper.workHandle).isNull() } + ?: { + assertThat(helper.workProfilePresent).isFalse() + assertThat(helper.workProfile).isNull() + assertThat(helper.workHandle).isNull() + } privateProfile?.also { assertThat(helper.privateProfilePresent).isTrue() assertThat(helper.privateProfile).isEqualTo(it) assertThat(helper.privateHandle).isEqualTo(it.primary.handle) - } ?: { - assertThat(helper.privateProfilePresent).isFalse() - assertThat(helper.privateProfile).isNull() - assertThat(helper.privateHandle).isNull() } + ?: { + assertThat(helper.privateProfilePresent).isFalse() + assertThat(helper.privateProfile).isNull() + assertThat(helper.privateHandle).isNull() + } } - @Test fun launchedByPersonal() = runTest { val repository = FakeUserRepository(listOf(personalUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) assertProfiles(helper, personalProfile) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.getQueryIntentsHandle(personalUser.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) } @@ -115,14 +113,14 @@ class ProfileHelperTest { fun launchedByPersonal_withClone() = runTest { val repository = FakeUserRepository(listOf(personalUser, cloneUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) assertProfiles(helper, personalWithCloneProfile) @@ -136,49 +134,46 @@ class ProfileHelperTest { fun launchedByClone() = runTest { val repository = FakeUserRepository(listOf(personalUser, cloneUser)) val interactor = UserInteractor(repository, launchedAs = cloneUser.handle) - val availability = interactor.availability.first() - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) assertProfiles(helper, personalWithCloneProfile) assertThat(helper.isLaunchedAsCloneProfile).isTrue() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.getQueryIntentsHandle(personalWithCloneProfile.primary.handle)) - .isEqualTo(personalWithCloneProfile.clone?.handle) + .isEqualTo(personalWithCloneProfile.clone?.handle) assertThat(helper.tabOwnerUserHandleForLaunch) - .isEqualTo(personalWithCloneProfile.primary.handle) + .isEqualTo(personalWithCloneProfile.primary.handle) } @Test fun launchedByPersonal_withWork() = runTest { val repository = FakeUserRepository(listOf(personalUser, workUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - - assertProfiles(helper, - personalProfile = personalProfile, - workProfile = workProfile) + assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile) assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.getQueryIntentsHandle(personalUser.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.getQueryIntentsHandle(workUser.handle)) - .isEqualTo(workProfile.primary.handle) + .isEqualTo(workProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) } @@ -186,52 +181,47 @@ class ProfileHelperTest { fun launchedByWork() = runTest { val repository = FakeUserRepository(listOf(personalUser, workUser)) val interactor = UserInteractor(repository, launchedAs = workUser.handle) - val availability = interactor.availability.first() - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - assertProfiles(helper, - personalProfile = personalProfile, - workProfile = workProfile) + assertProfiles(helper, personalProfile = personalProfile, workProfile = workProfile) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.WORK) assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.getQueryIntentsHandle(workProfile.primary.handle)) - .isEqualTo(workProfile.primary.handle) - assertThat(helper.tabOwnerUserHandleForLaunch) - .isEqualTo(workProfile.primary.handle) + .isEqualTo(workProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(workProfile.primary.handle) } @Test fun launchedByPersonal_withPrivate() = runTest { val repository = FakeUserRepository(listOf(personalUser, privateUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - assertProfiles(helper, - personalProfile = personalProfile, - privateProfile = privateProfile) + assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) - .isEqualTo(privateProfile.primary.handle) + .isEqualTo(privateProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) } @@ -239,26 +229,23 @@ class ProfileHelperTest { fun launchedByPrivate() = runTest { val repository = FakeUserRepository(listOf(personalUser, privateUser)) val interactor = UserInteractor(repository, launchedAs = privateUser.handle) - val availability = interactor.availability.first() - val launchedBy = interactor.launchedAsProfile.first() - - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - assertProfiles(helper, - personalProfile = personalProfile, - privateProfile = privateProfile) + assertProfiles(helper, personalProfile = personalProfile, privateProfile = privateProfile) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PRIVATE) assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) - .isEqualTo(privateProfile.primary.handle) + .isEqualTo(privateProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(privateProfile.primary.handle) } @@ -268,23 +255,21 @@ class ProfileHelperTest { val repository = FakeUserRepository(listOf(personalUser, privateUser)) val interactor = UserInteractor(repository, launchedAs = personalUser.handle) - val availability = interactor.availability.first() - val launchedBy = interactor.launchedAsProfile.first() - val helper = ProfileHelper( - interactor = interactor, - flags = flags, - profiles = interactor.profiles.first(), - launchedAsProfile = launchedBy) + val helper = + ProfileHelper( + interactor = interactor, + scope = this, + background = Dispatchers.Unconfined, + flags = flags + ) - assertProfiles(helper, - personalProfile = personalProfile, - privateProfile = null) + assertProfiles(helper, personalProfile = personalProfile, privateProfile = null) assertThat(helper.isLaunchedAsCloneProfile).isFalse() assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) assertThat(helper.getQueryIntentsHandle(personalProfile.primary.handle)) - .isEqualTo(personalProfile.primary.handle) + .isEqualTo(personalProfile.primary.handle) assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) } -}
\ No newline at end of file +} diff --git a/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt index 2953a650..d8cb7adc 100644 --- a/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt @@ -29,11 +29,17 @@ import com.android.intentresolver.ResolverListAdapter.ResolverListCommunicator import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.util.TestExecutor import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import org.junit.Test -import org.mockito.Mockito.anyBoolean -import org.mockito.Mockito.inOrder -import org.mockito.Mockito.never -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever private const val PKG_NAME = "org.pkg.app" private const val PKG_NAME_TWO = "org.pkg.two.app" @@ -43,20 +49,15 @@ private const val CLASS_NAME = "org.pkg.app.TheClass" class ResolverListAdapterTest { private val layoutInflater = mock<LayoutInflater>() private val packageManager = mock<PackageManager>() - private val userManager = mock<UserManager> { whenever(isManagedProfile).thenReturn(false) } + private val userManager = mock<UserManager> { on { isManagedProfile } doReturn (false) } private val context = mock<Context> { - whenever(getSystemService(Context.LAYOUT_INFLATER_SERVICE)).thenReturn(layoutInflater) - whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) - whenever(packageManager).thenReturn(this@ResolverListAdapterTest.packageManager) + on { getSystemService(Context.LAYOUT_INFLATER_SERVICE) } doReturn layoutInflater + on { getSystemService(Context.USER_SERVICE) } doReturn userManager + on { packageManager } doReturn this@ResolverListAdapterTest.packageManager } private val targetIntent = Intent(Intent.ACTION_SEND) private val payloadIntents = listOf(targetIntent) - private val resolverListController = - mock<ResolverListController> { - whenever(filterIneligibleActivities(any(), anyBoolean())).thenReturn(null) - whenever(filterLowPriority(any(), anyBoolean())).thenReturn(null) - } private val resolverListCommunicator = FakeResolverListCommunicator() private val userHandle = UserHandle.of(UserHandle.USER_CURRENT) private val targetDataLoader = mock<TargetDataLoader>() @@ -66,16 +67,20 @@ class ResolverListAdapterTest { @Test fun test_oneTargetNoLastChosen_oneTargetInAdapter() { val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + } val testSubject = ResolverListAdapter( context, @@ -111,18 +116,21 @@ class ResolverListAdapterTest { @Test fun test_oneTargetThatWasLastChosen_NoTargetsInAdapter() { val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) - whenever(resolverListController.lastChosen) - .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + on { lastChosen } doReturn resolvedTargets[0].getResolveInfoAt(0) + } val testSubject = ResolverListAdapter( context, @@ -157,18 +165,21 @@ class ResolverListAdapterTest { @Test fun test_oneTargetLastChosenNotInTheList_oneTargetInAdapter() { val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) - whenever(resolverListController.lastChosen) - .thenReturn(createResolveInfo(PKG_NAME_TWO, CLASS_NAME)) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + on { lastChosen } doReturn createResolveInfo(PKG_NAME_TWO, CLASS_NAME, userHandle) + } val testSubject = ResolverListAdapter( context, @@ -195,7 +206,9 @@ class ResolverListAdapterTest { assertThat(testSubject.hasFilteredItem()).isTrue() assertThat(testSubject.filteredItem).isNull() assertThat(testSubject.filteredPosition).isLessThan(0) - assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertWithMessage("unfilteredResolveList") + .that(testSubject.unfilteredResolveList) + .containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @@ -203,18 +216,21 @@ class ResolverListAdapterTest { @Test fun test_oneTargetThatWasLastChosenFilteringDisabled_oneTargetInAdapter() { val resolvedTargets = createResolvedComponents(ComponentName(PKG_NAME, CLASS_NAME)) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) - whenever(resolverListController.lastChosen) - .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + on { lastChosen } doReturn resolvedTargets[0].getResolveInfoAt(0) + } val testSubject = ResolverListAdapter( context, @@ -242,7 +258,9 @@ class ResolverListAdapterTest { assertThat(testSubject.hasFilteredItem()).isFalse() assertThat(testSubject.filteredItem).isNull() assertThat(testSubject.filteredPosition).isLessThan(0) - assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) + assertWithMessage("unfilteredResolveList") + .that(testSubject.unfilteredResolveList) + .containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() } @@ -272,20 +290,23 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - if (hasLastChosen) { - whenever(resolverListController.lastChosen) - .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) - } - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + if (hasLastChosen) { + on { lastChosen } doReturn resolvedTargets[0].getResolveInfoAt(0) + } + } val resolverListCommunicator = FakeResolverListCommunicator(useLayoutWithDefaults) val testSubject = ResolverListAdapter( @@ -346,18 +367,21 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - whenever(resolverListController.lastChosen) - .thenReturn(createResolveInfo(PKG_NAME, CLASS_NAME + "2")) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + on { lastChosen } doReturn createResolveInfo(PKG_NAME, CLASS_NAME + "2", userHandle) + } val testSubject = ResolverListAdapter( context, @@ -410,19 +434,22 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME_TWO, CLASS_NAME), ) resolvedTargets[1].getResolveInfoAt(0).targetUserId = 10 - whenever(resolvedTargets[1].getResolveInfoAt(0).loadLabel(any())).thenReturn("Label") - whenever(resolverListController.lastChosen) - .thenReturn(resolvedTargets[0].getResolveInfoAt(0)) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) + // whenever(resolvedTargets[1].getResolveInfoAt(0).loadLabel(any())).thenReturn("Label") + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + on { lastChosen } doReturn resolvedTargets[0].getResolveInfoAt(0) + } val testSubject = ResolverListAdapter( context, @@ -463,21 +490,26 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) - whenever(resolverListController.sort(any())).thenAnswer { invocation -> - val components = invocation.arguments[0] as MutableList<ResolvedComponentInfo> - components[0] = components[1].also { components[1] = components[0] } - null - } + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + on { sort(any()) } doAnswer + { + val components = it.arguments[0] as MutableList<ResolvedComponentInfo> + components[0] = components[1].also { components[1] = components[0] } + null + } + } val testSubject = ResolverListAdapter( context, @@ -516,22 +548,26 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) - whenever(resolverListController.filterIneligibleActivities(any(), anyBoolean())) - .thenAnswer { invocation -> - val components = invocation.arguments[0] as MutableList<ResolvedComponentInfo> - val original = ArrayList(components) - components.removeAt(1) - original + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + on { filterIneligibleActivities(any(), any()) } doAnswer + { + val components = it.arguments[0] as MutableList<ResolvedComponentInfo> + val original = ArrayList(components) + components.removeAt(1) + original + } } val testSubject = ResolverListAdapter( @@ -565,24 +601,28 @@ class ResolverListAdapterTest { @Suppress("UNCHECKED_CAST") @Test fun test_baseResolveList_excludedFromIneligibleActivityFiltering() { - val rList = listOf(createResolveInfo(PKG_NAME, CLASS_NAME)) - whenever(resolverListController.addResolveListDedupe(any(), eq(targetIntent), eq(rList))) - .thenAnswer { invocation -> - val result = invocation.arguments[0] as MutableList<ResolvedComponentInfo> - result.addAll( - createResolvedComponents( - ComponentName(PKG_NAME, CLASS_NAME), - ComponentName(PKG_NAME_TWO, CLASS_NAME), - ) - ) - null - } - whenever(resolverListController.filterIneligibleActivities(any(), anyBoolean())) - .thenAnswer { invocation -> - val components = invocation.arguments[0] as MutableList<ResolvedComponentInfo> - val original = ArrayList(components) - components.clear() - original + val rList = listOf(createResolveInfo(PKG_NAME, CLASS_NAME, userHandle)) + val resolverListController = + mock<ResolverListController> { + on { filterLowPriority(any(), any()) } doReturn null + on { addResolveListDedupe(any(), eq(targetIntent), eq(rList)) } doAnswer + { + val result = it.arguments[0] as MutableList<ResolvedComponentInfo> + result.addAll( + createResolvedComponents( + ComponentName(PKG_NAME, CLASS_NAME), + ComponentName(PKG_NAME_TWO, CLASS_NAME), + ) + ) + null + } + on { filterIneligibleActivities(any(), any()) } doAnswer + { + val components = it.arguments[0] as MutableList<ResolvedComponentInfo> + val original = ArrayList(components) + components.clear() + original + } } val testSubject = ResolverListAdapter( @@ -619,23 +659,26 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) - whenever(resolverListController.filterLowPriority(any(), anyBoolean())).thenAnswer { - invocation -> - val components = invocation.arguments[0] as MutableList<ResolvedComponentInfo> - val original = ArrayList(components) - components.removeAt(1) - original - } + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + on { filterLowPriority(any(), any()) } doAnswer + { + val components = it.arguments[0] as MutableList<ResolvedComponentInfo> + val original = ArrayList(components) + components.removeAt(1) + original + } + } val testSubject = ResolverListAdapter( context, @@ -672,19 +715,23 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) val initialComponent = ComponentName(PKG_NAME_THREE, CLASS_NAME) val initialIntents = arrayOf(Intent(Intent.ACTION_SEND).apply { component = initialComponent }) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + } whenever(packageManager.getActivityInfo(eq(initialComponent), eq(0))) .thenReturn(createActivityInfo(initialComponent)) val testSubject = @@ -742,16 +789,20 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - whenever( - resolverListController.getResolversForIntentAsUser( - true, - resolverListCommunicator.shouldGetActivityMetadata(), - resolverListCommunicator.shouldGetOnlyDefaultActivities(), - payloadIntents, - userHandle - ) - ) - .thenReturn(resolvedTargets) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { + getResolversForIntentAsUser( + true, + resolverListCommunicator.shouldGetActivityMetadata(), + resolverListCommunicator.shouldGetOnlyDefaultActivities(), + payloadIntents, + userHandle + ) + } doReturn resolvedTargets + } val initialComponent = ComponentName(PKG_NAME_TWO, CLASS_NAME) val initialIntents = arrayOf(Intent(Intent.ACTION_SEND).apply { component = initialComponent }) @@ -808,6 +859,7 @@ class ResolverListAdapterTest { @Test fun testPostListReadyAtEndOfRebuild_synchronous() { val communicator = mock<ResolverListCommunicator> {} + val resolverListController = mock<ResolverListController>() val testSubject = ResolverListAdapter( context, @@ -839,26 +891,16 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - // TODO: there's a lot of boilerplate required for this test even to trigger the expected - // conditions; if the configuration is incorrect, the test may accidentally pass for the - // wrong reasons. Separating responsibilities to other components will help minimize the - // *amount* of boilerplate, but we should also consider setting up test defaults that work - // according to our usual expectations so that we don't overlook false-negative results. - whenever( - resolverListController.getResolversForIntentAsUser( - any(), - any(), - any(), - any(), - any(), - ) - ) - .thenReturn(resolvedTargets) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { getResolversForIntentAsUser(any(), any(), any(), any(), any()) } doReturn + resolvedTargets + } val communicator = mock<ResolverListCommunicator> { - whenever(getReplacementIntent(any(), any())).thenAnswer { invocation -> - invocation.arguments[1] - } + on { getReplacementIntent(any(), any()) } doAnswer { it.arguments[1] as Intent } } val testSubject = ResolverListAdapter( @@ -897,26 +939,16 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - // TODO: there's a lot of boilerplate required for this test even to trigger the expected - // conditions; if the configuration is incorrect, the test may accidentally pass for the - // wrong reasons. Separating responsibilities to other components will help minimize the - // *amount* of boilerplate, but we should also consider setting up test defaults that work - // according to our usual expectations so that we don't overlook false-negative results. - whenever( - resolverListController.getResolversForIntentAsUser( - any(), - any(), - any(), - any(), - any(), - ) - ) - .thenReturn(resolvedTargets) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { getResolversForIntentAsUser(any(), any(), any(), any(), any()) } doReturn + resolvedTargets + } val communicator = mock<ResolverListCommunicator> { - whenever(getReplacementIntent(any(), any())).thenAnswer { invocation -> - invocation.arguments[1] - } + on { getReplacementIntent(any(), any()) } doAnswer { it.arguments[1] as Intent } } val testSubject = ResolverListAdapter( @@ -962,26 +994,16 @@ class ResolverListAdapterTest { ComponentName(PKG_NAME, CLASS_NAME), ComponentName(PKG_NAME_TWO, CLASS_NAME), ) - // TODO: there's a lot of boilerplate required for this test even to trigger the expected - // conditions; if the configuration is incorrect, the test may accidentally pass for the - // wrong reasons. Separating responsibilities to other components will help minimize the - // *amount* of boilerplate, but we should also consider setting up test defaults that work - // according to our usual expectations so that we don't overlook false-negative results. - whenever( - resolverListController.getResolversForIntentAsUser( - any(), - any(), - any(), - any(), - any(), - ) - ) - .thenReturn(resolvedTargets) + val resolverListController = + mock<ResolverListController> { + on { filterIneligibleActivities(any(), any()) } doReturn null + on { filterLowPriority(any(), any()) } doReturn null + on { getResolversForIntentAsUser(any(), any(), any(), any(), any()) } doReturn + resolvedTargets + } val communicator = mock<ResolverListCommunicator> { - whenever(getReplacementIntent(any(), any())).thenAnswer { invocation -> - invocation.arguments[1] - } + on { getReplacementIntent(any(), any()) } doAnswer { it.arguments[1] as Intent } } val testSubject = ResolverListAdapter( @@ -1023,17 +1045,23 @@ class ResolverListAdapterTest { ResolvedComponentInfo( ComponentName(PKG_NAME, CLASS_NAME), targetIntent, - createResolveInfo(component.packageName, component.className) + createResolveInfo(component.packageName, component.className, userHandle) ) result.add(resolvedComponentInfo) } return result } - private fun createResolveInfo(packageName: String, className: String): ResolveInfo = - mock<ResolveInfo> { + private fun createResolveInfo( + packageName: String, + className: String, + handle: UserHandle, + label: String? = null + ): ResolveInfo = + ResolveInfo().apply { activityInfo = createActivityInfo(ComponentName(packageName, className)) - targetUserId = this@ResolverListAdapterTest.userHandle.identifier - userHandle = this@ResolverListAdapterTest.userHandle + targetUserId = handle.identifier + userHandle = handle + nonLocalizedLabel = label } } diff --git a/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index 2346d98b..e26dffb8 100644 --- a/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -22,13 +22,15 @@ import android.content.Intent import android.content.pm.ShortcutInfo import android.os.UserHandle import android.service.chooser.ChooserTarget -import com.android.intentresolver.chooser.DisplayResolveInfo -import com.android.intentresolver.chooser.TargetInfo import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.chooser.TargetInfo import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock private const val PACKAGE_A = "package.a" private const val PACKAGE_B = "package.b" @@ -36,39 +38,43 @@ private const val CLASS_NAME = "./MainActivity" @SmallTest class ShortcutSelectionLogicTest { - private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser() + private val PERSONAL_USER_HANDLE: UserHandle = + InstrumentationRegistry.getInstrumentation().getTargetContext().getUser() - 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), - ) + 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 } - this[pkg] = targets } - } - private val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( + private val baseDisplayInfo = + DisplayResolveInfo.newDisplayResolveInfo( Intent(), ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), "label", "extended info", Intent() - ) + ) - private val otherBaseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( + private val otherBaseDisplayInfo = + DisplayResolveInfo.newDisplayResolveInfo( Intent(), ResolverDataProvider.createResolveInfo(4, 0, PERSONAL_USER_HANDLE), "label 2", "extended info 2", Intent() - ) + ) private operator fun Map<String, Array<ChooserTarget>>.get(pkg: String, idx: Int) = this[pkg]?.get(idx) ?: error("missing package $pkg") @@ -78,24 +84,26 @@ class ShortcutSelectionLogicTest { val serviceResults = ArrayList<TargetInfo>() val sc1 = packageTargets[PACKAGE_A, 0] val sc2 = packageTargets[PACKAGE_A, 1] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ false - ) + 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 - ) + 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( @@ -110,24 +118,26 @@ class ShortcutSelectionLogicTest { val serviceResults = ArrayList<TargetInfo>() val sc1 = packageTargets[PACKAGE_A, 0] val sc2 = packageTargets[PACKAGE_A, 1] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ true - ) + 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 - ) + 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( @@ -142,24 +152,26 @@ class ShortcutSelectionLogicTest { val serviceResults = ArrayList<TargetInfo>() val sc1 = packageTargets[PACKAGE_A, 0] val sc2 = packageTargets[PACKAGE_A, 1] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ false - ) + 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 - ) + 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( @@ -176,10 +188,11 @@ class ShortcutSelectionLogicTest { 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 - ) + val testSubject = + ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ true + ) testSubject.addServiceResults( /* origTarget = */ baseDisplayInfo, @@ -220,30 +233,31 @@ class ShortcutSelectionLogicTest { val serviceResults = ArrayList<TargetInfo>() val sc1 = packageTargets[PACKAGE_A, 0] val sc2 = packageTargets[PACKAGE_A, 1] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ false - ) + 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 - ) + 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( @@ -259,13 +273,12 @@ class ShortcutSelectionLogicTest { 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()) - } + val testSubject = + ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ true + ) + val context = mock<Context> { on { packageManager } doReturn (mock()) } testSubject.addServiceResults( /* origTarget = */ baseDisplayInfo, @@ -291,7 +304,9 @@ class ShortcutSelectionLogicTest { // 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? = "" + expected: List<ChooserTarget>, + actual: List<TargetInfo>, + msg: String? = "" ) { assertEquals(msg, expected.size, actual.size) for (i in expected.indices) { diff --git a/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt b/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt index e62672a3..92848b2c 100644 --- a/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt +++ b/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt @@ -16,189 +16,211 @@ 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. + * 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() - } + 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/tests/unit/src/com/android/intentresolver/TestHelpers.kt b/tests/unit/src/com/android/intentresolver/TestHelpers.kt index 5b583fef..812ecd1b 100644 --- a/tests/unit/src/com/android/intentresolver/TestHelpers.kt +++ b/tests/unit/src/com/android/intentresolver/TestHelpers.kt @@ -25,25 +25,17 @@ 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 +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock internal fun createShareShortcutInfo( id: String, componentName: ComponentName, rank: Int -): ShareShortcutInfo = - ShareShortcutInfo( - createShortcutInfo(id, componentName, rank), - componentName - ) +): 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) +internal fun createShortcutInfo(id: String, componentName: ComponentName, rank: Int): ShortcutInfo { + val context = mock<Context> { on { packageName } doReturn componentName.packageName } return ShortcutInfo.Builder(context, id) .setShortLabel("Short Label $id") .setLongLabel("Long Label $id") @@ -60,7 +52,10 @@ internal fun createAppTarget(shortcutInfo: ShortcutInfo) = ) fun createChooserTarget( - title: String, score: Float, componentName: ComponentName, shortcutId: String + title: String, + score: Float, + componentName: ComponentName, + shortcutId: String ): ChooserTarget = ChooserTarget( title, diff --git a/tests/unit/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/tests/unit/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt index 6712bf31..4d9d4880 100644 --- a/tests/unit/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt +++ b/tests/unit/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt @@ -23,17 +23,17 @@ import android.content.ComponentName import android.content.Intent import android.os.Bundle import android.os.UserHandle -import com.android.intentresolver.createShortcutInfo -import com.android.intentresolver.mock +import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.ResolverActivity import com.android.intentresolver.ResolverDataProvider +import com.android.intentresolver.createShortcutInfo import com.google.common.truth.Truth.assertThat import org.junit.Test -import androidx.test.platform.app.InstrumentationRegistry +import org.mockito.kotlin.mock class ImmutableTargetInfoTest { - private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser() + private val PERSONAL_USER_HANDLE: UserHandle = + InstrumentationRegistry.getInstrumentation().getTargetContext().getUser() private val resolvedIntent = Intent("resolved") private val targetIntent = Intent("target") @@ -46,61 +46,62 @@ class ImmutableTargetInfoTest { private val displayIconHolder: TargetInfo.IconHolder = mock() private val sourceIntent1 = Intent("source1") private val sourceIntent2 = Intent("source2") - private val displayTarget1 = DisplayResolveInfo.newDisplayResolveInfo( - Intent("display1"), - ResolverDataProvider.createResolveInfo(2, 0, PERSONAL_USER_HANDLE), - "display1 label", - "display1 extended info", - Intent("display1_resolved") - ) - private val displayTarget2 = DisplayResolveInfo.newDisplayResolveInfo( - Intent("display2"), - ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), - "display2 label", - "display2 extended info", - Intent("display2_resolved") - ) - private val directShareShortcutInfo = createShortcutInfo( - "shortcutid", ResolverDataProvider.createComponentName(4), 4) - private val directShareAppTarget = AppTarget( - AppTargetId("apptargetid"), - "test.directshare", - "target", - UserHandle.CURRENT) - private val displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( - Intent("displayresolve"), - ResolverDataProvider.createResolveInfo(5, 0, PERSONAL_USER_HANDLE), - "displayresolve label", - "displayresolve extended info", - Intent("display_resolved") - ) + private val displayTarget1 = + DisplayResolveInfo.newDisplayResolveInfo( + Intent("display1"), + ResolverDataProvider.createResolveInfo(2, 0, PERSONAL_USER_HANDLE), + "display1 label", + "display1 extended info", + Intent("display1_resolved") + ) + private val displayTarget2 = + DisplayResolveInfo.newDisplayResolveInfo( + Intent("display2"), + ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), + "display2 label", + "display2 extended info", + Intent("display2_resolved") + ) + private val directShareShortcutInfo = + createShortcutInfo("shortcutid", ResolverDataProvider.createComponentName(4), 4) + private val directShareAppTarget = + AppTarget(AppTargetId("apptargetid"), "test.directshare", "target", UserHandle.CURRENT) + private val displayResolveInfo = + DisplayResolveInfo.newDisplayResolveInfo( + Intent("displayresolve"), + ResolverDataProvider.createResolveInfo(5, 0, PERSONAL_USER_HANDLE), + "displayresolve label", + "displayresolve extended info", + Intent("display_resolved") + ) private val hashProvider: ImmutableTargetInfo.TargetHashProvider = mock() @Test - fun testBasicProperties() { // Fields that are reflected back w/o logic. + fun testBasicProperties() { // Fields that are reflected back w/o logic. // TODO: we could consider passing copies of all the values into the builder so that we can // verify that they're not mutated (e.g. no extras added to the intents). For now that // should be obvious from the implementation. - val info = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(resolvedIntent) - .setTargetIntent(targetIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .setResolvedComponentName(resolvedComponentName) - .setChooserTargetComponentName(chooserTargetComponentName) - .setResolveInfo(resolveInfo) - .setDisplayLabel(displayLabel) - .setExtendedInfo(extendedInfo) - .setDisplayIconHolder(displayIconHolder) - .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) - .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) - .setIsSuspended(true) - .setIsPinned(true) - .setModifiedScore(42.0f) - .setDirectShareShortcutInfo(directShareShortcutInfo) - .setDirectShareAppTarget(directShareAppTarget) - .setDisplayResolveInfo(displayResolveInfo) - .setHashProvider(hashProvider) - .build() + val info = + ImmutableTargetInfo.newBuilder() + .setResolvedIntent(resolvedIntent) + .setTargetIntent(targetIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .setResolvedComponentName(resolvedComponentName) + .setChooserTargetComponentName(chooserTargetComponentName) + .setResolveInfo(resolveInfo) + .setDisplayLabel(displayLabel) + .setExtendedInfo(extendedInfo) + .setDisplayIconHolder(displayIconHolder) + .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) + .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) + .setIsSuspended(true) + .setIsPinned(true) + .setModifiedScore(42.0f) + .setDirectShareShortcutInfo(directShareShortcutInfo) + .setDirectShareAppTarget(directShareAppTarget) + .setDisplayResolveInfo(displayResolveInfo) + .setHashProvider(hashProvider) + .build() assertThat(info.resolvedIntent).isEqualTo(resolvedIntent) assertThat(info.targetIntent).isEqualTo(targetIntent) @@ -111,8 +112,8 @@ class ImmutableTargetInfoTest { assertThat(info.displayLabel).isEqualTo(displayLabel) assertThat(info.extendedInfo).isEqualTo(extendedInfo) assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) - assertThat(info.allSourceIntents).containsExactly( - resolvedIntent, sourceIntent1, sourceIntent2) + assertThat(info.allSourceIntents) + .containsExactly(resolvedIntent, sourceIntent1, sourceIntent2) assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) assertThat(info.isSuspended).isTrue() assertThat(info.isPinned).isTrue() @@ -134,26 +135,27 @@ class ImmutableTargetInfoTest { fun testToBuilderPreservesBasicProperties() { // Note this is set up exactly as in `testBasicProperties`, but the assertions will be made // against a *copy* of the object instead. - val infoToCopyFrom = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(resolvedIntent) - .setTargetIntent(targetIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .setResolvedComponentName(resolvedComponentName) - .setChooserTargetComponentName(chooserTargetComponentName) - .setResolveInfo(resolveInfo) - .setDisplayLabel(displayLabel) - .setExtendedInfo(extendedInfo) - .setDisplayIconHolder(displayIconHolder) - .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) - .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) - .setIsSuspended(true) - .setIsPinned(true) - .setModifiedScore(42.0f) - .setDirectShareShortcutInfo(directShareShortcutInfo) - .setDirectShareAppTarget(directShareAppTarget) - .setDisplayResolveInfo(displayResolveInfo) - .setHashProvider(hashProvider) - .build() + val infoToCopyFrom = + ImmutableTargetInfo.newBuilder() + .setResolvedIntent(resolvedIntent) + .setTargetIntent(targetIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .setResolvedComponentName(resolvedComponentName) + .setChooserTargetComponentName(chooserTargetComponentName) + .setResolveInfo(resolveInfo) + .setDisplayLabel(displayLabel) + .setExtendedInfo(extendedInfo) + .setDisplayIconHolder(displayIconHolder) + .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) + .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) + .setIsSuspended(true) + .setIsPinned(true) + .setModifiedScore(42.0f) + .setDirectShareShortcutInfo(directShareShortcutInfo) + .setDirectShareAppTarget(directShareAppTarget) + .setDisplayResolveInfo(displayResolveInfo) + .setHashProvider(hashProvider) + .build() val info = infoToCopyFrom.toBuilder().build() @@ -166,8 +168,8 @@ class ImmutableTargetInfoTest { assertThat(info.displayLabel).isEqualTo(displayLabel) assertThat(info.extendedInfo).isEqualTo(extendedInfo) assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) - assertThat(info.allSourceIntents).containsExactly( - resolvedIntent, sourceIntent1, sourceIntent2) + assertThat(info.allSourceIntents) + .containsExactly(resolvedIntent, sourceIntent1, sourceIntent2) assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) assertThat(info.isSuspended).isTrue() assertThat(info.isPinned).isTrue() @@ -199,12 +201,13 @@ class ImmutableTargetInfoTest { val referrerFillInIntent = Intent("REFERRER_FILL_IN") referrerFillInIntent.setPackage("referrer") - val info = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .build() + val info = + ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .build() - assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty. + assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty. assertThat(info.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN") } @@ -216,13 +219,12 @@ class ImmutableTargetInfoTest { val refinementIntent = Intent() refinementIntent.putExtra("REFINEMENT", true) - val originalInfo = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .build() + val originalInfo = + ImmutableTargetInfo.newBuilder().setResolvedIntent(originalIntent).build() val info = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinementIntent)) - assertThat(info?.baseIntentToSend?.getBooleanExtra("ORIGINAL", false)).isTrue() - assertThat(info?.baseIntentToSend?.getBooleanExtra("REFINEMENT", false)).isTrue() + assertThat(info.baseIntentToSend?.getBooleanExtra("ORIGINAL", false)).isTrue() + assertThat(info.baseIntentToSend?.getBooleanExtra("REFINEMENT", false)).isTrue() } @Test @@ -234,20 +236,21 @@ class ImmutableTargetInfoTest { referrerFillInIntent.setPackage("referrer_pkg") referrerFillInIntent.setType("test/referrer") - val infoWithReferrerFillIn = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .build() + val infoWithReferrerFillIn = + ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .build() val refinementIntent = Intent("REFINE_ME") - refinementIntent.setPackage("original") // Has to match for refinement. + refinementIntent.setPackage("original") // Has to match for refinement. val info = checkNotNull(infoWithReferrerFillIn.tryToCloneWithAppliedRefinement(refinementIntent)) - assertThat(info?.baseIntentToSend?.getPackage()).isEqualTo("original") // Set all along. - assertThat(info?.baseIntentToSend?.action).isEqualTo("REFINE_ME") // Refinement wins. - assertThat(info?.baseIntentToSend?.type).isEqualTo("test/referrer") // Left for referrer. + assertThat(info.baseIntentToSend?.getPackage()).isEqualTo("original") // Set all along. + assertThat(info.baseIntentToSend?.action).isEqualTo("REFINE_ME") // Refinement wins. + assertThat(info.baseIntentToSend?.type).isEqualTo("test/referrer") // Left for referrer. } @Test @@ -260,25 +263,26 @@ class ImmutableTargetInfoTest { val refinementIntent2 = Intent("REFINE_ME") refinementIntent2.putExtra("TEST2", "2") - val originalInfo = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .build() + val originalInfo = + ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setReferrerFillInIntent(referrerFillInIntent) + .build() val refined1 = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinementIntent1)) // Cloned clone. val refined2 = checkNotNull(refined1.tryToCloneWithAppliedRefinement(refinementIntent2)) // Both clones get the same values filled in from the referrer intent. - assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER") - assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER") + assertThat(refined1.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER") + assertThat(refined2.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER") // Each clone has the respective value that was set in their own refinement request. - assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST1")).isEqualTo("1") - assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST2")).isEqualTo("2") + assertThat(refined1.baseIntentToSend?.getStringExtra("TEST1")).isEqualTo("1") + assertThat(refined2.baseIntentToSend?.getStringExtra("TEST2")).isEqualTo("2") // The clones don't have the data from each other's refinements, even though the intent // field is empty (thus able to be populated by filling-in). - assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST2")).isNull() - assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST1")).isNull() + assertThat(refined1.baseIntentToSend?.getStringExtra("TEST2")).isNull() + assertThat(refined2.baseIntentToSend?.getStringExtra("TEST1")).isNull() } @Test @@ -292,25 +296,27 @@ class ImmutableTargetInfoTest { val extraMatch = Intent("REFINE_ME") extraMatch.putExtra("extraMatch", true) - val originalInfo = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setAllSourceIntents(listOf( - originalIntent, mismatchedAlternate, targetAlternate, extraMatch)) - .build() + val originalInfo = + ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setAllSourceIntents( + listOf(originalIntent, mismatchedAlternate, targetAlternate, extraMatch) + ) + .build() - val refinement = Intent("REFINE_ME") // First match is `targetAlternate` + val refinement = Intent("REFINE_ME") // First match is `targetAlternate` refinement.putExtra("refinement", true) val refinedResult = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinement)) - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("refinement", false)).isTrue() - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("targetAlternate", false)) + assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("refinement", false)).isTrue() + assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("targetAlternate", false)) .isTrue() // None of the other source intents got merged in (not even the later one that matched): - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("originalIntent", false)) + assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("originalIntent", false)) .isFalse() - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("mismatchedAlternate", false)) + assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("mismatchedAlternate", false)) .isFalse() - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("extraMatch", false)).isFalse() + assertThat(refinedResult.baseIntentToSend?.getBooleanExtra("extraMatch", false)).isFalse() } @Test @@ -320,10 +326,11 @@ class ImmutableTargetInfoTest { val mismatchedAlternate = Intent("DOESNT_MATCH") mismatchedAlternate.putExtra("mismatchedAlternate", true) - val originalInfo = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setAllSourceIntents(listOf(originalIntent, mismatchedAlternate)) - .build() + val originalInfo = + ImmutableTargetInfo.newBuilder() + .setResolvedIntent(originalIntent) + .setAllSourceIntents(listOf(originalIntent, mismatchedAlternate)) + .build() val refinement = Intent("PROPOSED_REFINEMENT") assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() @@ -331,9 +338,10 @@ class ImmutableTargetInfoTest { @Test fun testLegacySubclassRelationships_empty() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO) - .build() + val info = + ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO) + .build() assertThat(info.isEmptyTargetInfo).isTrue() assertThat(info.isPlaceHolderTargetInfo).isFalse() @@ -346,9 +354,10 @@ class ImmutableTargetInfoTest { @Test fun testLegacySubclassRelationships_placeholder() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO) - .build() + val info = + ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO) + .build() assertThat(info.isEmptyTargetInfo).isFalse() assertThat(info.isPlaceHolderTargetInfo).isTrue() @@ -361,9 +370,10 @@ class ImmutableTargetInfoTest { @Test fun testLegacySubclassRelationships_selectable() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.SELECTABLE_TARGET_INFO) - .build() + val info = + ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.SELECTABLE_TARGET_INFO) + .build() assertThat(info.isEmptyTargetInfo).isFalse() assertThat(info.isPlaceHolderTargetInfo).isFalse() @@ -376,9 +386,10 @@ class ImmutableTargetInfoTest { @Test fun testLegacySubclassRelationships_displayResolveInfo() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.DISPLAY_RESOLVE_INFO) - .build() + val info = + ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.DISPLAY_RESOLVE_INFO) + .build() assertThat(info.isEmptyTargetInfo).isFalse() assertThat(info.isPlaceHolderTargetInfo).isFalse() @@ -391,9 +402,10 @@ class ImmutableTargetInfoTest { @Test fun testLegacySubclassRelationships_multiDisplayResolveInfo() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO) - .build() + val info = + ImmutableTargetInfo.newBuilder() + .setLegacyType(ImmutableTargetInfo.LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO) + .build() assertThat(info.isEmptyTargetInfo).isFalse() assertThat(info.isPlaceHolderTargetInfo).isFalse() @@ -406,13 +418,17 @@ class ImmutableTargetInfoTest { @Test fun testActivityStarter_correctNumberOfInvocations_startAsCaller() { - val activityStarter = object : TestActivityStarter() { - override fun startAsUser( - target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle - ): Boolean { - throw RuntimeException("Wrong API used: startAsUser") + val activityStarter = + object : TestActivityStarter() { + override fun startAsUser( + target: TargetInfo, + activity: Activity, + options: Bundle, + user: UserHandle + ): Boolean { + throw RuntimeException("Wrong API used: startAsUser") + } } - } val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() val activity: ResolverActivity = mock() @@ -431,12 +447,17 @@ class ImmutableTargetInfoTest { @Test fun testActivityStarter_correctNumberOfInvocations_startAsUser() { - val activityStarter = object : TestActivityStarter() { - override fun startAsCaller( - target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { - throw RuntimeException("Wrong API used: startAsCaller") + val activityStarter = + object : TestActivityStarter() { + override fun startAsCaller( + target: TargetInfo, + activity: Activity, + options: Bundle, + userId: Int + ): Boolean { + throw RuntimeException("Wrong API used: startAsCaller") + } } - } val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() val activity: Activity = mock() @@ -466,7 +487,7 @@ class ImmutableTargetInfoTest { info2.startAsUser(mock(), Bundle(), UserHandle.of(42)) assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) - assertThat(activityStarter.totalInvocations).isEqualTo(3) // Instance is still shared. + assertThat(activityStarter.totalInvocations).isEqualTo(3) // Instance is still shared. } } @@ -475,27 +496,35 @@ private open class TestActivityStarter : ImmutableTargetInfo.TargetActivityStart var lastInvocationTargetInfo: TargetInfo? = null var lastInvocationActivity: Activity? = null var lastInvocationOptions: Bundle? = null - var lastInvocationUserId: Integer? = null + var lastInvocationUserId: Int? = null var lastInvocationAsCaller = false override fun startAsCaller( - target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { + target: TargetInfo, + activity: Activity, + options: Bundle, + userId: Int + ): Boolean { ++totalInvocations lastInvocationTargetInfo = target lastInvocationActivity = activity lastInvocationOptions = options - lastInvocationUserId = Integer(userId) + lastInvocationUserId = userId lastInvocationAsCaller = true return true } override fun startAsUser( - target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle): Boolean { + target: TargetInfo, + activity: Activity, + options: Bundle, + user: UserHandle + ): Boolean { ++totalInvocations lastInvocationTargetInfo = target lastInvocationActivity = activity lastInvocationOptions = options - lastInvocationUserId = Integer(user.identifier) + lastInvocationUserId = user.identifier lastInvocationAsCaller = false return true } diff --git a/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt index b346bee5..a2f6e7a4 100644 --- a/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -30,16 +30,16 @@ import com.android.intentresolver.ResolverDataProvider import com.android.intentresolver.ResolverDataProvider.createResolveInfo import com.android.intentresolver.createChooserTarget import com.android.intentresolver.createShortcutInfo -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test -import org.mockito.Mockito.any -import org.mockito.Mockito.never -import org.mockito.Mockito.spy -import org.mockito.Mockito.times -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify class TargetInfoTest { private val PERSONAL_USER_HANDLE: UserHandle = @@ -422,9 +422,7 @@ class TargetInfoTest { ) ) val targetTwo = - mock<DisplayResolveInfo> { - whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this) - } + mock<DisplayResolveInfo> { on { tryToCloneWithAppliedRefinement(any()) } doReturn mock } val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo(listOf(targetOne, targetTwo)) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt new file mode 100644 index 00000000..331f9f64 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import kotlin.math.ceil +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CachingImagePreviewImageLoaderTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val testJobTime = 100.milliseconds + private val testCacheSize = 4 + private val testMaxConcurrency = 2 + private val testTimeToFillCache = + testJobTime * ceil((testCacheSize).toFloat() / testMaxConcurrency.toFloat()).roundToInt() + private val testUris = + List(5) { Uri.fromParts("TestScheme$it", "TestSsp$it", "TestFragment$it") } + private val testTimeToLoadAllUris = + testJobTime * ceil((testUris.size).toFloat() / testMaxConcurrency.toFloat()).roundToInt() + private val testBitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8) + private val fakeThumbnailLoader = + FakeThumbnailLoader().apply { + testUris.forEach { + fakeInvoke[it] = { + delay(testJobTime) + testBitmap + } + } + } + + private val imageLoader = + CachingImagePreviewImageLoader( + scope = testScope.backgroundScope, + bgDispatcher = testDispatcher, + thumbnailLoader = fakeThumbnailLoader, + cacheSize = testCacheSize, + maxConcurrency = testMaxConcurrency, + ) + + @Test + fun loadImage_notCached_callsThumbnailLoader() = + testScope.runTest { + // Arrange + var result: Bitmap? = null + + // Act + imageLoader.loadImage(testScope, testUris[0]) { result = it } + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) + assertThat(result).isSameInstanceAs(testBitmap) + } + + @Test + fun loadImage_cached_usesCachedValue() = + testScope.runTest { + // Arrange + imageLoader.loadImage(testScope, testUris[0]) {} + advanceTimeBy(testJobTime) + runCurrent() + fakeThumbnailLoader.invokeCalls.clear() + var result: Bitmap? = null + + // Act + imageLoader.loadImage(testScope, testUris[0]) { result = it } + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + assertThat(result).isSameInstanceAs(testBitmap) + } + + @Test + fun loadImage_error_returnsNull() = + testScope.runTest { + // Arrange + fakeThumbnailLoader.fakeInvoke[testUris[0]] = { + delay(testJobTime) + throw RuntimeException("Test exception") + } + var result: Bitmap? = testBitmap + + // Act + imageLoader.loadImage(testScope, testUris[0]) { result = it } + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) + assertThat(result).isNull() + } + + @Test + fun loadImage_uncached_limitsConcurrency() = + testScope.runTest { + // Arrange + val results = mutableListOf<Bitmap?>() + assertThat(testUris.size).isGreaterThan(testMaxConcurrency) + + // Act + testUris.take(testMaxConcurrency + 1).forEach { uri -> + imageLoader.loadImage(testScope, uri) { results.add(it) } + } + + // Assert + assertThat(results).isEmpty() + advanceTimeBy(testJobTime) + runCurrent() + assertThat(results).hasSize(testMaxConcurrency) + advanceTimeBy(testJobTime) + runCurrent() + assertThat(results).hasSize(testMaxConcurrency + 1) + assertThat(results) + .containsExactlyElementsIn(List(testMaxConcurrency + 1) { testBitmap }) + } + + @Test + fun loadImage_cacheEvicted_cancelsLoadAndReturnsNull() = + testScope.runTest { + // Arrange + val results = MutableList<Bitmap?>(testUris.size) { null } + assertThat(testUris.size).isGreaterThan(testCacheSize) + + // Act + imageLoader.loadImage(testScope, testUris[0]) { results[0] = it } + runCurrent() + testUris.indices.drop(1).take(testCacheSize).forEach { i -> + imageLoader.loadImage(testScope, testUris[i]) { results[i] = it } + } + advanceTimeBy(testTimeToFillCache) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(testUris) + assertThat(results) + .containsExactlyElementsIn( + List(testUris.size) { index -> if (index == 0) null else testBitmap } + ) + .inOrder() + assertThat(fakeThumbnailLoader.unfinishedInvokeCount).isEqualTo(1) + } + + @Test + fun prePopulate_fillsCache() = + testScope.runTest { + // Arrange + val fullCacheUris = testUris.take(testCacheSize) + assertThat(fullCacheUris).hasSize(testCacheSize) + + // Act + imageLoader.prePopulate(fullCacheUris) + advanceTimeBy(testTimeToFillCache) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(fullCacheUris) + + // Act + fakeThumbnailLoader.invokeCalls.clear() + imageLoader.prePopulate(fullCacheUris) + advanceTimeBy(testTimeToFillCache) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + } + + @Test + fun prePopulate_greaterThanCacheSize_fillsCacheThenDropsRemaining() = + testScope.runTest { + // Arrange + assertThat(testUris.size).isGreaterThan(testCacheSize) + + // Act + imageLoader.prePopulate(testUris) + advanceTimeBy(testTimeToLoadAllUris) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls) + .containsExactlyElementsIn(testUris.take(testCacheSize)) + + // Act + fakeThumbnailLoader.invokeCalls.clear() + imageLoader.prePopulate(testUris) + advanceTimeBy(testTimeToLoadAllUris) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + } + + @Test + fun prePopulate_fewerThatCacheSize_loadsTheGiven() = + testScope.runTest { + // Arrange + val unfilledCacheUris = testUris.take(testMaxConcurrency) + assertThat(unfilledCacheUris.size).isLessThan(testCacheSize) + + // Act + imageLoader.prePopulate(unfilledCacheUris) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(unfilledCacheUris) + + // Act + fakeThumbnailLoader.invokeCalls.clear() + imageLoader.prePopulate(unfilledCacheUris) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() + } + + @Test + fun invoke_uncached_alwaysCallsTheThumbnailLoader() = + testScope.runTest { + // Arrange + + // Act + imageLoader.invoke(testUris[0], caching = false) + imageLoader.invoke(testUris[0], caching = false) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0], testUris[0]) + } + + @Test + fun invoke_cached_usesTheCacheWhenPossible() = + testScope.runTest { + // Arrange + + // Act + imageLoader.invoke(testUris[0], caching = true) + imageLoader.invoke(testUris[0], caching = true) + advanceTimeBy(testJobTime) + runCurrent() + + // Assert + assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index c7c3c516..27d98ece 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -21,10 +21,8 @@ import android.net.Uri import android.platform.test.flag.junit.CheckFlagsRule import android.platform.test.flag.junit.DeviceFlagsValueProvider import com.android.intentresolver.ContentTypeHint -import com.android.intentresolver.TestPreviewImageLoader +import com.android.intentresolver.FakeImageLoader import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow import com.android.intentresolver.widget.ImagePreviewView import com.google.common.truth.Truth.assertThat @@ -38,12 +36,14 @@ import org.junit.Test import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever class ChooserContentPreviewUiTest { private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) private val previewData = mock<PreviewDataProvider>() private val headlineGenerator = mock<HeadlineGenerator>() - private val imageLoader = TestPreviewImageLoader(emptyMap()) + private val imageLoader = FakeImageLoader(emptyMap()) private val testMetadataText: CharSequence = "Test metadata text" private val actionFactory = object : ActionFactory { @@ -70,6 +70,7 @@ class ChooserContentPreviewUiTest { targetIntent, imageLoader, actionFactory, + { null }, transitionCallback, headlineGenerator, ContentTypeHint.NONE, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt new file mode 100644 index 00000000..0c346095 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CursorReadSizeTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.database.MatrixCursor +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH +import android.util.Size +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CursorReadSizeTest { + @Test + fun missingSizeColumns() { + val cursor = MatrixCursor(arrayOf("column")).apply { addRow(arrayOf("abc")) } + cursor.moveToFirst() + + assertThat(cursor.readSize()).isNull() + } + + @Test + fun testIncorrectSizeValues() = runTest { + val cursor = + MatrixCursor(arrayOf(WIDTH, HEIGHT)).apply { + addRow(arrayOf(null, null)) + addRow(arrayOf("100", null)) + addRow(arrayOf(null, "100")) + addRow(arrayOf("-100", "100")) + addRow(arrayOf("100", "-100")) + addRow(arrayOf("100", "abc")) + addRow(arrayOf("abc", "100")) + } + + var i = 0 + while (cursor.moveToNext()) { + i++ + assertWithMessage("Row $i").that(cursor.readSize()).isNull() + } + } + + @Test + fun testCorrectSizeValues() = runTest { + val cursor = + MatrixCursor(arrayOf(HEIGHT, WIDTH)).apply { + addRow(arrayOf("100", 0)) + addRow(arrayOf("100", "50")) + } + + cursor.moveToNext() + assertThat(cursor.readSize()).isEqualTo(Size(0, 100)) + + cursor.moveToNext() + assertThat(cursor.readSize()).isEqualTo(Size(50, 100)) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt deleted file mode 100644 index cd1c503a..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.ContentInterface -import android.content.Intent -import android.database.MatrixCursor -import android.net.Uri -import android.util.SparseArray -import com.android.intentresolver.any -import com.android.intentresolver.anyOrNull -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class CursorUriReaderTest { - private val scope = TestScope() - - @Test - fun readEmptyCursor() { - val testSubject = - CursorUriReader( - cursor = MatrixCursor(arrayOf("uri")), - startPos = 0, - pageSize = 128, - ) { - true - } - - assertThat(testSubject.hasMoreBefore).isFalse() - assertThat(testSubject.hasMoreAfter).isFalse() - assertThat(testSubject.count).isEqualTo(0) - assertThat(testSubject.readPageBefore().size()).isEqualTo(0) - assertThat(testSubject.readPageAfter().size()).isEqualTo(0) - } - - @Test - fun readCursorFromTheMiddle() { - val count = 3 - val testSubject = - CursorUriReader( - cursor = - MatrixCursor(arrayOf("uri")).apply { - for (i in 1..count) { - addRow(arrayOf(createUri(i))) - } - }, - startPos = 1, - pageSize = 2, - ) { - true - } - - assertThat(testSubject.hasMoreBefore).isTrue() - assertThat(testSubject.hasMoreAfter).isTrue() - assertThat(testSubject.count).isEqualTo(3) - - testSubject.readPageBefore().let { page -> - assertThat(testSubject.hasMoreBefore).isFalse() - assertThat(testSubject.hasMoreAfter).isTrue() - assertThat(page.size()).isEqualTo(1) - assertThat(page.keyAt(0)).isEqualTo(0) - assertThat(page.valueAt(0)).isEqualTo(createUri(1)) - } - - testSubject.readPageAfter().let { page -> - assertThat(testSubject.hasMoreBefore).isFalse() - assertThat(testSubject.hasMoreAfter).isFalse() - assertThat(page.size()).isEqualTo(2) - assertThat(page.getKeys()).asList().containsExactly(1, 2).inOrder() - assertThat(page.getValues()) - .asList() - .containsExactly(createUri(2), createUri(3)) - .inOrder() - } - } - - // TODO: add tests with filtered-out items - // TODO: add tests with a failing cursor - - @Test - fun testFailingQueryCall_emptyCursorCreated() = - scope.runTest { - val contentResolver = - mock<ContentInterface> { - whenever(query(any(), any(), anyOrNull(), any())) - .thenThrow(SecurityException("Test exception")) - } - val cursorReader = - CursorUriReader.createCursorReader( - contentResolver, - Uri.parse("content://auth"), - Intent(Intent.ACTION_CHOOSER) - ) - - assertWithMessage("Empty cursor reader is expected") - .that(cursorReader.count) - .isEqualTo(0) - } -} - -private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") - -private fun <T> SparseArray<T>.getKeys(): IntArray = IntArray(size()) { i -> keyAt(i) } - -private inline fun <reified T> SparseArray<T>.getValues(): Array<T> = - Array(size()) { i -> valueAt(i) } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt index a540dfa2..7c50fa42 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt @@ -23,13 +23,13 @@ import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.R -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow import com.google.common.truth.Truth.assertThat import java.util.function.Consumer import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock @RunWith(AndroidJUnit4::class) class FileContentPreviewUiTest { @@ -45,7 +45,7 @@ class FileContentPreviewUiTest { override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {} } private val headlineGenerator = - mock<HeadlineGenerator> { whenever(getFilesHeadline(fileCount)).thenReturn(text) } + mock<HeadlineGenerator> { on { getFilesHeadline(fileCount) } doReturn text } private val context get() = InstrumentationRegistry.getInstrumentation().context @@ -61,47 +61,27 @@ class FileContentPreviewUiTest { @Test fun test_display_titleAndMetadataIsDisplayed() { val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + + assertThat(headlineRow.findViewById<View>(R.id.headline)).isNull() + assertThat(headlineRow.findViewById<View>(R.id.metadata)).isNull() val previewView = testSubject.display( context.resources, layoutInflater, gridLayout, - /*headlineViewParent=*/ null + headlineRow, ) assertThat(previewView).isNotNull() - val headlineView = previewView?.findViewById<TextView>(R.id.headline) - assertThat(headlineView).isNotNull() - assertThat(headlineView?.text).isEqualTo(text) - val metadataView = previewView?.findViewById<TextView>(R.id.metadata) - assertThat(metadataView?.text).isEqualTo(testMetadataText) - } - - @Test - fun test_displayWithExternalHeaderView() { - val layoutInflater = LayoutInflater.from(context) - val gridLayout = - layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) - as ViewGroup - val externalHeaderView = - gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) - - assertThat(externalHeaderView.findViewById<View>(R.id.headline)).isNull() - assertThat(externalHeaderView.findViewById<View>(R.id.metadata)).isNull() - - val previewView = - testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) - - assertThat(previewView).isNotNull() - assertThat(previewView.findViewById<View>(R.id.headline)).isNull() - assertThat(previewView.findViewById<View>(R.id.metadata)).isNull() - - val headlineView = externalHeaderView.findViewById<TextView>(R.id.headline) + val headlineView = headlineRow.findViewById<TextView>(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) - val metadataView = externalHeaderView.findViewById<TextView>(R.id.metadata) + val metadataView = headlineRow.findViewById<TextView>(R.id.metadata) assertThat(metadataView?.text).isEqualTo(testMetadataText) } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt index 259ffdac..1d85c61b 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -25,8 +25,6 @@ import androidx.annotation.IdRes import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.android.intentresolver.R -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage @@ -36,10 +34,12 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify private const val HEADLINE_IMAGES = "Image Headline" private const val HEADLINE_VIDEOS = "Video Headline" @@ -52,17 +52,21 @@ class FilesPlusTextContentPreviewUiTest { private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { override fun getEditButtonRunnable(): Runnable? = null + override fun getCopyButtonRunnable(): Runnable? = null + override fun createCustomActions(): List<ActionRow.Action> = emptyList() + override fun getModifyShareAction(): ActionRow.Action? = null + override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {} } private val imageLoader = mock<ImageLoader>() private val headlineGenerator = mock<HeadlineGenerator> { - whenever(getImagesHeadline(anyInt())).thenReturn(HEADLINE_IMAGES) - whenever(getVideosHeadline(anyInt())).thenReturn(HEADLINE_VIDEOS) - whenever(getFilesHeadline(anyInt())).thenReturn(HEADLINE_FILES) + on { getImagesHeadline(any()) } doReturn HEADLINE_IMAGES + on { getVideosHeadline(any()) } doReturn HEADLINE_VIDEOS + on { getFilesHeadline(any()) } doReturn HEADLINE_FILES } private val testMetadataText: CharSequence = "Test metadata text" @@ -70,200 +74,106 @@ class FilesPlusTextContentPreviewUiTest { get() = getInstrumentation().context @Test - fun test_displayImagesPlusTextWithoutUriMetadata_showImagesHeadline() { + fun test_displayImagesPlusTextWithoutUriMetadataHeader_showImagesHeadline() { val sharedFileCount = 2 - val previewView = testLoadingHeadline("image/*", sharedFileCount) - - verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_IMAGES) - verifyPreviewMetadata(previewView, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayImagesPlusTextWithoutUriMetadataExternalHeader_showImagesHeadline() { - val sharedFileCount = 2 - val (previewView, headerParent) = testLoadingExternalHeadline("image/*", sharedFileCount) + val (previewView, headlineRow) = testLoadingHeadline("image/*", sharedFileCount) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) - verifyPreviewMetadata(headerParent, testMetadataText) + verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayVideosPlusTextWithoutUriMetadata_showVideosHeadline() { + fun test_displayVideosPlusTextWithoutUriMetadataHeader_showVideosHeadline() { val sharedFileCount = 2 - val previewView = testLoadingHeadline("video/*", sharedFileCount) + val (previewView, headlineRow) = testLoadingHeadline("video/*", sharedFileCount) verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) - verifyPreviewMetadata(previewView, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayVideosPlusTextWithoutUriMetadataExternalHeader_showVideosHeadline() { - val sharedFileCount = 2 - val (previewView, headerParent) = testLoadingExternalHeadline("video/*", sharedFileCount) - - verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) - verifyPreviewMetadata(headerParent, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayDocsPlusTextWithoutUriMetadata_showFilesHeadline() { - val sharedFileCount = 2 - val previewView = testLoadingHeadline("application/pdf", sharedFileCount) - - verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayDocsPlusTextWithoutUriMetadataExternalHeader_showFilesHeadline() { - val sharedFileCount = 2 - val (previewView, headerParent) = - testLoadingExternalHeadline("application/pdf", sharedFileCount) - - verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_FILES) - verifyPreviewMetadata(headerParent, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_VIDEOS) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayMixedContentPlusTextWithoutUriMetadata_showFilesHeadline() { + fun test_displayDocsPlusTextWithoutUriMetadataHeader_showFilesHeadline() { val sharedFileCount = 2 - val previewView = testLoadingHeadline("*/*", sharedFileCount) + val (previewView, headlineRow) = testLoadingHeadline("application/pdf", sharedFileCount) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayMixedContentPlusTextWithoutUriMetadataExternalHeader_showFilesHeadline() { + fun test_displayMixedContentPlusTextWithoutUriMetadataHeader_showFilesHeadline() { val sharedFileCount = 2 - val (previewView, headerParent) = testLoadingExternalHeadline("*/*", sharedFileCount) + val (previewView, headlineRow) = testLoadingHeadline("*/*", sharedFileCount) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_FILES) - verifyPreviewMetadata(headerParent, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayImagesPlusTextWithUriMetadataSet_showImagesHeadline() { - val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg") - val sharedFileCount = loadedFileMetadata.size - val previewView = testLoadingHeadline("image/*", sharedFileCount, loadedFileMetadata) - - verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_IMAGES) - verifyPreviewMetadata(previewView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayImagesPlusTextWithUriMetadataSetExternalHeader_showImagesHeadline() { + fun test_displayImagesPlusTextWithUriMetadataSetHeader_showImagesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg") val sharedFileCount = loadedFileMetadata.size - val (previewView, headerParent) = - testLoadingExternalHeadline("image/*", sharedFileCount, loadedFileMetadata) + val (previewView, headlineRow) = + testLoadingHeadline("image/*", sharedFileCount, loadedFileMetadata) verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) - verifyPreviewMetadata(headerParent, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayVideosPlusTextWithUriMetadataSet_showVideosHeadline() { - val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4") - val sharedFileCount = loadedFileMetadata.size - val previewView = testLoadingHeadline("video/*", sharedFileCount, loadedFileMetadata) - - verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) - verifyPreviewMetadata(previewView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayVideosPlusTextWithUriMetadataSetExternalHeader_showVideosHeadline() { + fun test_displayVideosPlusTextWithUriMetadataSetHeader_showVideosHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4") val sharedFileCount = loadedFileMetadata.size - val (previewView, headerParent) = - testLoadingExternalHeadline("video/*", sharedFileCount, loadedFileMetadata) + val (previewView, headlineRow) = + testLoadingHeadline("video/*", sharedFileCount, loadedFileMetadata) verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) - verifyPreviewMetadata(headerParent, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayImagesAndVideosPlusTextWithUriMetadataSet_showFilesHeadline() { - val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4") - val sharedFileCount = loadedFileMetadata.size - val previewView = testLoadingHeadline("*/*", sharedFileCount, loadedFileMetadata) - - verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_VIDEOS) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayImagesAndVideosPlusTextWithUriMetadataSetExternalHeader_showFilesHeadline() { + fun test_displayImagesAndVideosPlusTextWithUriMetadataSetHeader_showFilesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4") val sharedFileCount = loadedFileMetadata.size - val (previewView, headerParent) = - testLoadingExternalHeadline("*/*", sharedFileCount, loadedFileMetadata) + val (previewView, headlineRow) = + testLoadingHeadline("*/*", sharedFileCount, loadedFileMetadata) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_FILES) - verifyPreviewMetadata(headerParent, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @Test - fun test_displayDocsPlusTextWithUriMetadataSet_showFilesHeadline() { + fun test_displayDocsPlusTextWithUriMetadataSetHeader_showFilesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf") val sharedFileCount = loadedFileMetadata.size - val previewView = + val (previewView, headlineRow) = testLoadingHeadline("application/pdf", sharedFileCount, loadedFileMetadata) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) - verifySharedText(previewView) - } - - @Test - fun test_displayDocsPlusTextWithUriMetadataSetExternalHeader_showFilesHeadline() { - val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf") - val sharedFileCount = loadedFileMetadata.size - val (previewView, headerParent) = - testLoadingExternalHeadline("application/pdf", sharedFileCount, loadedFileMetadata) - - verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(headerParent, HEADLINE_FILES) - verifyPreviewMetadata(headerParent, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) verifySharedText(previewView) } @@ -284,26 +194,33 @@ class FilesPlusTextContentPreviewUiTest { testMetadataText, ) val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) - val previewView = - testSubject.display(context.resources, LayoutInflater.from(context), gridLayout, null) + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + headlineRow + ) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_FILES) - verifyPreviewMetadata(previewView, testMetadataText) + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg")) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(previewView, HEADLINE_IMAGES) - verifyPreviewMetadata(previewView, testMetadataText) + verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES) + verifyPreviewMetadata(headlineRow, testMetadataText) } @Test - fun test_uriMetadataIsMoreSpecificThanIntentMimeTypeExternalHeader_headlineGetsUpdated() { + fun test_uriMetadataIsMoreSpecificThanIntentMimeTypeHeader_headlineGetsUpdated() { val sharedFileCount = 2 val testSubject = FilesPlusTextContentPreviewUi( @@ -322,14 +239,13 @@ class FilesPlusTextContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup - val externalHeaderView = - gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) - assertWithMessage("External headline should not be inflated by default") - .that(externalHeaderView.findViewById<View>(R.id.headline)) + assertWithMessage("Headline should not be inflated by default") + .that(headlineRow.findViewById<View>(R.id.headline)) .isNull() - assertWithMessage("External metadata should not be inflated by default") - .that(externalHeaderView.findViewById<View>(R.id.metadata)) + assertWithMessage("Metadata should not be inflated by default") + .that(headlineRow.findViewById<View>(R.id.metadata)) .isNull() val previewView = @@ -337,57 +253,27 @@ class FilesPlusTextContentPreviewUiTest { context.resources, LayoutInflater.from(context), gridLayout, - externalHeaderView + headlineRow ) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) - verifyInternalHeadlineAbsence(previewView) - verifyPreviewHeadline(externalHeaderView, HEADLINE_FILES) - verifyPreviewMetadata(externalHeaderView, testMetadataText) + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + verifyPreviewHeadline(headlineRow, HEADLINE_FILES) + verifyPreviewMetadata(headlineRow, testMetadataText) testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg")) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) - verifyPreviewHeadline(externalHeaderView, HEADLINE_IMAGES) - verifyPreviewMetadata(externalHeaderView, testMetadataText) + verifyPreviewHeadline(headlineRow, HEADLINE_IMAGES) + verifyPreviewMetadata(headlineRow, testMetadataText) } private fun testLoadingHeadline( intentMimeType: String, sharedFileCount: Int, loadedFileMetadata: List<FileInfo>? = null, - ): ViewGroup? { - val testSubject = - FilesPlusTextContentPreviewUi( - testScope, - /*isSingleImage=*/ false, - sharedFileCount, - SHARED_TEXT, - intentMimeType, - actionFactory, - imageLoader, - DefaultMimeTypeClassifier, - headlineGenerator, - testMetadataText, - ) - val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup - - loadedFileMetadata?.let(testSubject::updatePreviewMetadata) - return testSubject.display( - context.resources, - LayoutInflater.from(context), - gridLayout, - /*headlineViewParent=*/ null - ) - } - - private fun testLoadingExternalHeadline( - intentMimeType: String, - sharedFileCount: Int, - loadedFileMetadata: List<FileInfo>? = null, ): Pair<ViewGroup?, View> { val testSubject = FilesPlusTextContentPreviewUi( @@ -406,15 +292,14 @@ class FilesPlusTextContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup - val externalHeaderView = - gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) - assertWithMessage("External headline should not be inflated by default") - .that(externalHeaderView.findViewById<View>(R.id.headline)) + assertWithMessage("Headline should not be inflated by default") + .that(headlineRow.findViewById<View>(R.id.headline)) .isNull() - assertWithMessage("External metadata should not be inflated by default") - .that(externalHeaderView.findViewById<View>(R.id.metadata)) + assertWithMessage("Metadata should not be inflated by default") + .that(headlineRow.findViewById<View>(R.id.metadata)) .isNull() loadedFileMetadata?.let(testSubject::updatePreviewMetadata) @@ -422,8 +307,8 @@ class FilesPlusTextContentPreviewUiTest { context.resources, LayoutInflater.from(context), gridLayout, - externalHeaderView - ) to externalHeaderView + headlineRow + ) to headlineRow } private fun createFileInfosWithMimeTypes(vararg mimeTypes: String): List<FileInfo> { @@ -453,13 +338,4 @@ class FilesPlusTextContentPreviewUiTest { private fun verifySharedText(previewView: ViewGroup?) { verifyTextViewText(previewView, R.id.content_preview_text, SHARED_TEXT) } - - private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { - assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() - assertWithMessage( - "Preview headline should not be inflated when an external headline is used" - ) - .that(previewView?.findViewById<View>(R.id.headline)) - .isNull() - } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt index 89978707..3a45e2f6 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt @@ -20,13 +20,6 @@ import android.content.ContentResolver import android.graphics.Bitmap import android.net.Uri import android.util.Size -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope -import androidx.lifecycle.testing.TestLifecycleOwner -import com.android.intentresolver.any -import com.android.intentresolver.anyOrNull -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat import java.util.ArrayDeque import java.util.concurrent.CountDownLatch @@ -38,29 +31,33 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Runnable import kotlinx.coroutines.async +import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import kotlinx.coroutines.yield -import org.junit.After import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class ImagePreviewImageLoaderTest { @@ -69,284 +66,291 @@ class ImagePreviewImageLoaderTest { private val uriTwo = Uri.parse("content://org.package.app/image-2.png") private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) private val contentResolver = - mock<ContentResolver> { - whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap) - } - private val lifecycleOwner = TestLifecycleOwner() - private val dispatcher = UnconfinedTestDispatcher() - private lateinit var testSubject: ImagePreviewImageLoader - - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - // create test subject after we've updated the lifecycle dispatcher - testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - } - - @After - fun cleanup() { - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - Dispatchers.resetMain() - } + mock<ContentResolver> { on { loadThumbnail(any(), any(), anyOrNull()) } doReturn bitmap } + private val scheduler = TestCoroutineScheduler() + private val dispatcher = UnconfinedTestDispatcher(scheduler) + private val scope = TestScope(dispatcher) + private val testSubject = + ImagePreviewImageLoader( + dispatcher, + imageSize.width, + contentResolver, + cacheSize = 1, + ) @Test - fun prePopulate_cachesImagesUpToTheCacheSize() = runTest { - testSubject.prePopulate(listOf(uriOne, uriTwo)) + fun prePopulate_cachesImagesUpToTheCacheSize() = + scope.runTest { + testSubject.prePopulate(listOf(uriOne, uriTwo)) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) - testSubject(uriOne) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } + testSubject(uriOne) + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + } @Test - fun invoke_returnCachedImageWhenCalledTwice() = runTest { - testSubject(uriOne) - testSubject(uriOne) + fun invoke_returnCachedImageWhenCalledTwice() = + scope.runTest { + testSubject(uriOne) + testSubject(uriOne) - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } + verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) + } @Test - fun invoke_whenInstructed_doesNotCache() = runTest { - testSubject(uriOne, false) - testSubject(uriOne, false) + fun invoke_whenInstructed_doesNotCache() = + scope.runTest { + testSubject(uriOne, false) + testSubject(uriOne, false) - verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) - } + verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) + } @Test - fun invoke_overlappedRequests_Deduplicate() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - scheduler.advanceUntilIdle() - } + fun invoke_overlappedRequests_Deduplicate() = + scope.runTest { + val dispatcher = StandardTestDispatcher(scheduler) + val testSubject = + ImagePreviewImageLoader( + dispatcher, + imageSize.width, + contentResolver, + cacheSize = 1, + ) + coroutineScope { + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + scheduler.advanceUntilIdle() + } - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } + verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) + } @Test - fun invoke_oldRecordsEvictedFromTheCache() = runTest { - testSubject(uriOne) - testSubject(uriTwo) - testSubject(uriTwo) - testSubject(uriOne) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) - } + fun invoke_oldRecordsEvictedFromTheCache() = + scope.runTest { + testSubject(uriOne) + testSubject(uriTwo) + testSubject(uriTwo) + testSubject(uriOne) + + verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) + verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) + } @Test - fun invoke_doNotCacheNulls() = runTest { - whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) - testSubject(uriOne) - testSubject(uriOne) + fun invoke_doNotCacheNulls() = + scope.runTest { + whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) + testSubject(uriOne) + testSubject(uriOne) - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - } + verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) + } @Test(expected = CancellationException::class) - fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest { - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - testSubject(uriOne) - } + fun invoke_onClosedImageLoaderScope_throwsCancellationException() = + scope.runTest { + val imageLoaderScope = CoroutineScope(coroutineContext) + val testSubject = + ImagePreviewImageLoader( + imageLoaderScope, + imageSize.width, + contentResolver, + cacheSize = 1, + ) + imageLoaderScope.cancel() + testSubject(uriOne) + } @Test(expected = CancellationException::class) - fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) } - lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - scheduler.advanceUntilIdle() - deferred.await() + fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = + scope.runTest { + val dispatcher = StandardTestDispatcher(scheduler) + val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher) + val testSubject = + ImagePreviewImageLoader( + imageLoaderScope, + imageSize.width, + contentResolver, + cacheSize = 1, + ) + coroutineScope { + val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) } + imageLoaderScope.cancel() + scheduler.advanceUntilIdle() + deferred.await() + } } - } @Test - fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, true) } - scheduler.advanceUntilIdle() - } - testSubject(uriOne, true) + fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = + scope.runTest { + val dispatcher = StandardTestDispatcher(scheduler) + val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher) + val testSubject = + ImagePreviewImageLoader( + imageLoaderScope, + imageSize.width, + contentResolver, + cacheSize = 1, + ) + coroutineScope { + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, true) } + scheduler.advanceUntilIdle() + } + testSubject(uriOne, true) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + } @Test - fun invoke_semaphoreGuardsContentResolverCalls() = runTest { - val contentResolver = - mock<ContentResolver> { - whenever(loadThumbnail(any(), any(), anyOrNull())) - .thenThrow(SecurityException("test")) - } - val acquireCount = AtomicInteger() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - acquireCount.getAndIncrement() + fun invoke_semaphoreGuardsContentResolverCalls() = + scope.runTest { + val contentResolver = + mock<ContentResolver> { + on { loadThumbnail(any(), any(), anyOrNull()) } doThrow + SecurityException("test") } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") + val acquireCount = AtomicInteger() + val releaseCount = AtomicInteger() + val testSemaphore = + object : Semaphore { + override val availablePermits: Int + get() = error("Unexpected invocation") + + override suspend fun acquire() { + acquireCount.getAndIncrement() + } + + override fun tryAcquire(): Boolean { + error("Unexpected invocation") + } + + override fun release() { + releaseCount.getAndIncrement() + } } - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - testSubject(uriOne, false) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(acquireCount.get()).isEqualTo(1) - assertThat(releaseCount.get()).isEqualTo(1) - } + val testSubject = + ImagePreviewImageLoader( + CoroutineScope(coroutineContext + dispatcher), + imageSize.width, + contentResolver, + cacheSize = 1, + testSemaphore, + ) + testSubject(uriOne, false) + + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + assertThat(acquireCount.get()).isEqualTo(1) + assertThat(releaseCount.get()).isEqualTo(1) + } @Test - fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = runTest { - val semaphoreDeferred = CompletableDeferred<Unit>() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - semaphoreDeferred.await() - } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") + fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = + scope.runTest { + val semaphoreDeferred = CompletableDeferred<Unit>() + val releaseCount = AtomicInteger() + val testSemaphore = + object : Semaphore { + override val availablePermits: Int + get() = error("Unexpected invocation") + + override suspend fun acquire() { + semaphoreDeferred.await() + } + + override fun tryAcquire(): Boolean { + error("Unexpected invocation") + } + + override fun release() { + releaseCount.getAndIncrement() + } } - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + val testSubject = + ImagePreviewImageLoader( + CoroutineScope(coroutineContext + dispatcher), + imageSize.width, + contentResolver, + cacheSize = 1, + testSemaphore, + ) + launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull()) + verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull()) - semaphoreDeferred.complete(Unit) + semaphoreDeferred.complete(Unit) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(releaseCount.get()).isEqualTo(1) - } + verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) + assertThat(releaseCount.get()).isEqualTo(1) + } @Test - fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() { - val requestCount = 4 - val thumbnailCallsCdl = CountDownLatch(requestCount) - val pendingThumbnailCalls = ArrayDeque<CountDownLatch>() - val contentResolver = - mock<ContentResolver> { - whenever(loadThumbnail(any(), any(), anyOrNull())).thenAnswer { - val latch = CountDownLatch(1) - synchronized(pendingThumbnailCalls) { pendingThumbnailCalls.offer(latch) } - thumbnailCallsCdl.countDown() - assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS)) - bitmap + fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() = + scope.runTest { + val requestCount = 4 + val thumbnailCallsCdl = CountDownLatch(requestCount) + val pendingThumbnailCalls = ArrayDeque<CountDownLatch>() + val contentResolver = + mock<ContentResolver> { + on { loadThumbnail(any(), any(), anyOrNull()) } doAnswer + { + val latch = CountDownLatch(1) + synchronized(pendingThumbnailCalls) { + pendingThumbnailCalls.offer(latch) + } + thumbnailCallsCdl.countDown() + assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS)) + bitmap + } } - } - val name = "LoadImage" - val maxSimultaneousRequests = 2 - val threadsStartedCdl = CountDownLatch(requestCount) - val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() } - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher + CoroutineName(name), - imageSize.width, - contentResolver, - cacheSize = 1, - maxSimultaneousRequests, - ) - runTest { - repeat(requestCount) { - launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) } - } - yield() - // wait for all requests to be dispatched - assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue() + val name = "LoadImage" + val maxSimultaneousRequests = 2 + val threadsStartedCdl = CountDownLatch(requestCount) + val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() } + val testSubject = + ImagePreviewImageLoader( + CoroutineScope(coroutineContext + dispatcher + CoroutineName(name)), + imageSize.width, + contentResolver, + cacheSize = 1, + maxSimultaneousRequests, + ) + coroutineScope { + repeat(requestCount) { + launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) } + } + yield() + // wait for all requests to be dispatched + assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } + assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() + synchronized(pendingThumbnailCalls) { + assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) + } - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } + pendingThumbnailCalls.poll()?.countDown() + assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() + synchronized(pendingThumbnailCalls) { + assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) + } - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - for (cdl in pendingThumbnailCalls) { - cdl.countDown() + pendingThumbnailCalls.poll()?.countDown() + assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue() + synchronized(pendingThumbnailCalls) { + assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) + } + for (cdl in pendingThumbnailCalls) { + cdl.countDown() + } } } - } } private class NewThreadDispatcher( diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt deleted file mode 100644 index 25c27468..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.Intent -import android.database.Cursor -import android.database.MatrixCursor -import android.net.Uri -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class PayloadToggleInteractorTest { - private val scheduler = TestCoroutineScheduler() - private val testScope = TestScope(scheduler) - - @Test - fun initialState() = - testScope.runTest { - val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true } - val testSubject = - PayloadToggleInteractor( - scope = testScope.backgroundScope, - initiallySharedUris = listOf(makeUri(0), makeUri(2), makeUri(5)), - focusedUriIdx = 1, - mimeTypeClassifier = DefaultMimeTypeClassifier, - cursorReaderProvider = { cursorReader }, - uriMetadataReader = { uri -> - FileInfo.Builder(uri) - .withMimeType("image/png") - .withPreviewUri(uri) - .build() - }, - selectionCallback = { null }, - targetIntentModifier = { Intent(Intent.ACTION_SEND) }, - ) - .apply { start() } - - scheduler.runCurrent() - - testSubject.stateFlow.first().let { initialState -> - assertWithMessage("Two pages (2 items each) are expected to be initially read") - .that(initialState.items) - .hasSize(4) - assertWithMessage("Unexpected cursor values") - .that(initialState.items.map { it.uri }) - .containsExactly(*Array<Uri>(4, ::makeUri)) - .inOrder() - assertWithMessage("No more items are expected to the left") - .that(initialState.hasMoreItemsBefore) - .isFalse() - assertWithMessage("No more items are expected to the right") - .that(initialState.hasMoreItemsAfter) - .isTrue() - assertWithMessage("Selections should no be disabled") - .that(initialState.allowSelectionChange) - .isTrue() - } - - testSubject.loadMoreNextItems() - // this one is expected to be deduplicated - testSubject.loadMoreNextItems() - scheduler.runCurrent() - - testSubject.stateFlow.first().let { state -> - assertWithMessage("Unexpected cursor values") - .that(state.items.map { it.uri }) - .containsExactly(*Array(6, ::makeUri)) - .inOrder() - assertWithMessage("No more items are expected to the left") - .that(state.hasMoreItemsBefore) - .isFalse() - assertWithMessage("No more items are expected to the right") - .that(state.hasMoreItemsAfter) - .isTrue() - assertWithMessage("Selections should no be disabled") - .that(state.allowSelectionChange) - .isTrue() - assertWithMessage("Wrong selected items") - .that(state.items.map { testSubject.selected(it).first() }) - .containsExactly(true, false, true, false, false, true) - .inOrder() - } - } - - @Test - fun testItemsSelection() = - testScope.runTest { - val cursorReader = CursorUriReader(createCursor(10), 2, 2) { true } - val testSubject = - PayloadToggleInteractor( - scope = testScope.backgroundScope, - initiallySharedUris = listOf(makeUri(0)), - focusedUriIdx = 1, - mimeTypeClassifier = DefaultMimeTypeClassifier, - cursorReaderProvider = { cursorReader }, - uriMetadataReader = { uri -> - FileInfo.Builder(uri) - .withMimeType("image/png") - .withPreviewUri(uri) - .build() - }, - selectionCallback = { null }, - targetIntentModifier = { Intent(Intent.ACTION_SEND) }, - ) - .apply { start() } - - scheduler.runCurrent() - val items = testSubject.stateFlow.first().items - assertWithMessage("An initially selected item should be selected") - .that(testSubject.selected(items[0]).first()) - .isTrue() - assertWithMessage("An item that was not initially selected should not be selected") - .that(testSubject.selected(items[1]).first()) - .isFalse() - - testSubject.setSelected(items[0], false) - scheduler.runCurrent() - assertWithMessage("The only selected item can not be unselected") - .that(testSubject.selected(items[0]).first()) - .isTrue() - - testSubject.setSelected(items[1], true) - scheduler.runCurrent() - assertWithMessage("An item selection status should be published") - .that(testSubject.selected(items[1]).first()) - .isTrue() - - testSubject.setSelected(items[0], false) - scheduler.runCurrent() - assertWithMessage("An item can be unselected when there's another selected item") - .that(testSubject.selected(items[0]).first()) - .isFalse() - } -} - -private fun createCursor(count: Int): Cursor { - return MatrixCursor(arrayOf("uri")).apply { - for (i in 0 until count) { - addRow(arrayOf(makeUri(i))) - } - } -} - -private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt index babfaaf5..a2fb9693 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -24,8 +24,6 @@ import android.net.Uri import android.platform.test.flag.junit.CheckFlagsRule import android.platform.test.flag.junit.DeviceFlagsValueProvider import android.provider.DocumentsContract -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineScope @@ -36,10 +34,12 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -import org.mockito.Mockito.any -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class PreviewDataProviderTest { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt deleted file mode 100644 index 1a59a930..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.Intent -import android.net.Uri -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class PreviewViewModelTest { - @OptIn(ExperimentalCoroutinesApi::class) private val dispatcher = UnconfinedTestDispatcher() - - private val context - get() = InstrumentationRegistry.getInstrumentation().targetContext - - private val targetIntent = Intent(Intent.ACTION_SEND) - private val chooserIntent = Intent.createChooser(targetIntent, null) - private val additionalContentUri = Uri.parse("content://org.pkg.content") - - @Test - fun featureFlagDisabled_noPayloadToggleInteractorCreated() { - val testSubject = - PreviewViewModel(context.contentResolver, 200, dispatcher).apply { - init( - targetIntent, - chooserIntent, - additionalContentUri, - focusedItemIdx = 0, - isPayloadTogglingEnabled = false - ) - } - - assertThat(testSubject.payloadToggleInteractor).isNull() - } - - @Test - fun noAdditionalContentUri_noPayloadToggleInteractorCreated() { - val testSubject = - PreviewViewModel(context.contentResolver, 200, dispatcher).apply { - init( - targetIntent, - chooserIntent, - additionalContentUri = null, - focusedItemIdx = 0, - true - ) - } - - assertThat(testSubject.payloadToggleInteractor).isNull() - } - - @Test - fun flagEnabledAndAdditionalContentUriProvided_createPayloadToggleInteractor() { - val testSubject = - PreviewViewModel(context.contentResolver, 200, dispatcher).apply { - init(targetIntent, chooserIntent, additionalContentUri, focusedItemIdx = 0, true) - } - - assertThat(testSubject.payloadToggleInteractor).isNotNull() - } -} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt deleted file mode 100644 index 6ba18466..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt +++ /dev/null @@ -1,330 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.net.Uri -import android.util.SparseArray -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class SelectionTrackerTest { - @Test - fun noSelectedItems() { - val testSubject = SelectionTracker<Uri>(emptyList(), 0, 10) { this } - - val items = - (1..5).fold(SparseArray<Uri>(5)) { acc, i -> - acc.apply { append(i * 2, makeUri(i * 2)) } - } - testSubject.onEndItemsAdded(items) - - assertThat(testSubject.getSelection()).isEmpty() - } - - @Test - fun testNoItems() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, 0) { this } - - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceAllItemsOnTheRight_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 7 - val testSubject = SelectionTracker(listOf(u1, u2, u3), 0, count) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(1, u1) - append(2, makeUri(4)) - append(3, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(3, makeUri(6)) - append(4, u2) - append(5, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceElementsOnBothSides_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 10 - val testSubject = SelectionTracker(listOf(u1, u2, u3), 1, count) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(4, u2) - append(5, makeUri(4)) - append(6, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(1, makeUri(6)) - append(2, u1) - append(3, makeUri(7)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(8, u3) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceAllItemsOnTheLeft_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 7 - val testSubject = SelectionTracker(listOf(u1, u2, u3), 2, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(6, u3) }) - - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(3, makeUri(4)) - append(4, u2) - append(5, makeUri(5)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(1, u1) - append(2, makeUri(6)) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3).inOrder() - } - - @Test - fun focusedItemInPlaceDuplicatesOnBothSides_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val count = 5 - val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(3).apply { append(2, u2) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(0, u1) - append(1, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(3, u1) - append(4, u3) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u1).inOrder() - } - - @Test - fun focusedItemInPlaceDuplicatesOnTheRight_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val count = 4 - val testSubject = SelectionTracker(listOf(u1, u2), 0, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(0, u1) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(1, u2) - append(2, u1) - append(3, u2) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - } - - @Test - fun focusedItemInPlaceDuplicatesOnTheLeft_selectionsInTheInitialOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val count = 4 - val testSubject = SelectionTracker(listOf(u1, u2), 1, count) { this } - - testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(3, u2) }) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(0, u1) - append(1, u2) - append(2, u1) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2).inOrder() - } - - @Test - fun differentItemsOrder_selectionsInTheCursorOrder() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val u4 = makeUri(3) - val count = 10 - val testSubject = SelectionTracker(listOf(u1, u2, u3, u4), 2, count) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(4, makeUri(5)) - append(5, u1) - append(6, makeUri(6)) - } - ) - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(2, makeUri(7)) - append(3, u4) - } - ) - testSubject.onEndItemsAdded( - SparseArray<Uri>(3).apply { - append(7, u3) - append(8, makeUri(8)) - } - ) - testSubject.onStartItemsAdded( - SparseArray<Uri>(3).apply { - append(0, makeUri(9)) - append(1, u2) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u2, u4, u1, u3).inOrder() - } - - @Test - fun testPendingItems() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val u4 = makeUri(4) - val u5 = makeUri(5) - - val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 5) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(2).apply { - append(2, u3) - append(3, u4) - } - ) - testSubject.onStartItemsAdded(SparseArray<Uri>(2).apply { append(1, u2) }) - - assertThat(testSubject.getPendingItems()).containsExactly(u1, u5).inOrder() - } - - @Test - fun testItemSelection() { - val u1 = makeUri(1) - val u2 = makeUri(2) - val u3 = makeUri(3) - val u4 = makeUri(4) - val u5 = makeUri(5) - - val testSubject = SelectionTracker(listOf(u1, u2, u3, u4, u5), 2, 10) { this } - - testSubject.onEndItemsAdded( - SparseArray<Uri>(2).apply { - append(2, u3) - append(3, u4) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder() - - assertThat(testSubject.setItemSelection(2, u3, false)).isTrue() - assertThat(testSubject.setItemSelection(3, u4, true)).isFalse() - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder() - - testSubject.onEndItemsAdded( - SparseArray<Uri>(1).apply { - append(4, u5) - append(5, u3) - } - ) - testSubject.onStartItemsAdded( - SparseArray<Uri>(2).apply { - append(0, u1) - append(1, u2) - } - ) - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u4, u5).inOrder() - - assertThat(testSubject.setItemSelection(2, u3, true)).isTrue() - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5).inOrder() - assertThat(testSubject.setItemSelection(5, u3, true)).isTrue() - assertThat(testSubject.getSelection()).containsExactly(u1, u2, u3, u4, u5, u3).inOrder() - } - - @Test - fun testItemSelectionWithDuplicates() { - val u1 = makeUri(1) - val u2 = makeUri(2) - - val testSubject = SelectionTracker(listOf(u1, u2, u1), 1, 3) { this } - testSubject.onEndItemsAdded( - SparseArray<Uri>(2).apply { - append(1, u2) - append(2, u1) - } - ) - - assertThat(testSubject.getPendingItems()).containsExactly(u1) - } - - @Test - fun testUnselectOnlySelectedItem_itemRemainsSelected() { - val u1 = makeUri(1) - - val testSubject = SelectionTracker(listOf(u1), 0, 1) { this } - testSubject.onEndItemsAdded(SparseArray<Uri>(1).apply { append(0, u1) }) - assertThat(testSubject.isItemSelected(0)).isTrue() - assertThat(testSubject.setItemSelection(0, u1, false)).isFalse() - assertThat(testSubject.isItemSelected(0)).isTrue() - } -} - -private fun makeUri(id: Int) = Uri.parse("content://org.pkg.app/img-$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt index 1c96070c..60159160 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -24,8 +24,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.R -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow import com.google.common.truth.Truth.assertThat import java.util.function.Consumer @@ -34,6 +32,8 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock @RunWith(AndroidJUnit4::class) class TextContentPreviewUiTest { @@ -44,16 +44,20 @@ class TextContentPreviewUiTest { private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { override fun getEditButtonRunnable(): Runnable? = null + override fun getCopyButtonRunnable(): Runnable? = null + override fun createCustomActions(): List<ActionRow.Action> = emptyList() + override fun getModifyShareAction(): ActionRow.Action? = null + override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {} } private val imageLoader = mock<ImageLoader>() private val headlineGenerator = mock<HeadlineGenerator> { - whenever(getTextHeadline(text)).thenReturn(text) - whenever(getAlbumHeadline()).thenReturn(albumHeadline) + on { getTextHeadline(text) } doReturn text + on { getAlbumHeadline() } doReturn albumHeadline } private val testMetadataText: CharSequence = "Test metadata text" @@ -76,56 +80,35 @@ class TextContentPreviewUiTest { @Test fun test_display_headlineIsDisplayed() { val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) val previewView = testSubject.display( context.resources, layoutInflater, gridLayout, - /*headlineViewParent=*/ null + headlineRow, ) assertThat(previewView).isNotNull() - val headlineView = previewView?.findViewById<TextView>(R.id.headline) + val headlineView = headlineRow.findViewById<TextView>(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) - val metadataView = previewView?.findViewById<TextView>(R.id.metadata) + val metadataView = headlineRow.findViewById<TextView>(R.id.metadata) assertThat(metadataView).isNotNull() assertThat(metadataView?.text).isEqualTo(testMetadataText) } @Test - fun test_displayWithExternalHeaderView_externalHeaderIsDisplayed() { + fun test_display_albumHeadlineOverride() { val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup - val externalHeaderView = - gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) - - assertThat(externalHeaderView.findViewById<View>(R.id.headline)).isNull() - assertThat(externalHeaderView.findViewById<View>(R.id.metadata)).isNull() - - val previewView = - testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) - - assertThat(previewView).isNotNull() - assertThat(previewView.findViewById<View>(R.id.headline)).isNull() - assertThat(previewView.findViewById<View>(R.id.metadata)).isNull() - - val headlineView = externalHeaderView.findViewById<TextView>(R.id.headline) - assertThat(headlineView).isNotNull() - assertThat(headlineView?.text).isEqualTo(text) - val metadataView = externalHeaderView.findViewById<TextView>(R.id.metadata) - assertThat(metadataView).isNotNull() - assertThat(metadataView?.text).isEqualTo(testMetadataText) - } - - @Test - fun test_display_albumHeadlineOverride() { - val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup + val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) val albumSubject = TextContentPreviewUi( @@ -145,14 +128,14 @@ class TextContentPreviewUiTest { context.resources, layoutInflater, gridLayout, - /*headlineViewParent=*/ null + headlineRow, ) assertThat(previewView).isNotNull() - val headlineView = previewView?.findViewById<TextView>(R.id.headline) + val headlineView = headlineRow.findViewById<TextView>(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(albumHeadline) - val metadataView = previewView?.findViewById<TextView>(R.id.metadata) + val metadataView = headlineRow.findViewById<TextView>(R.id.metadata) assertThat(metadataView).isNotNull() assertThat(metadataView?.text).isEqualTo(testMetadataText) } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt index faeaf133..21eb12ea 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -25,12 +25,11 @@ import androidx.annotation.IdRes import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.android.intentresolver.R -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.takeWhile @@ -39,9 +38,11 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.times -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify private const val IMAGE_HEADLINE = "Image Headline" private const val VIDEO_HEADLINE = "Video Headline" @@ -49,17 +50,18 @@ private const val FILES_HEADLINE = "Files Headline" @RunWith(AndroidJUnit4::class) class UnifiedContentPreviewUiTest { + @OptIn(ExperimentalCoroutinesApi::class) private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) private val actionFactory = mock<ChooserContentPreviewUi.ActionFactory> { - whenever(createCustomActions()).thenReturn(emptyList()) + on { createCustomActions() } doReturn emptyList() } private val imageLoader = mock<ImageLoader>() private val headlineGenerator = mock<HeadlineGenerator> { - whenever(getImagesHeadline(anyInt())).thenReturn(IMAGE_HEADLINE) - whenever(getVideosHeadline(anyInt())).thenReturn(VIDEO_HEADLINE) - whenever(getFilesHeadline(anyInt())).thenReturn(FILES_HEADLINE) + on { getImagesHeadline(any()) } doReturn IMAGE_HEADLINE + on { getVideosHeadline(any()) } doReturn VIDEO_HEADLINE + on { getFilesHeadline(any()) } doReturn FILES_HEADLINE } private val testMetadataText: CharSequence = "Test metadata text" @@ -67,233 +69,100 @@ class UnifiedContentPreviewUiTest { get() = getInstrumentation().context @Test - fun test_displayImagesWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("image/*", files = null) { previewView -> + fun test_displayImagesWithoutUriMetadataHeader_showImagesHeadline() { + testLoadingHeadline("image/*", files = null) { headlineRow -> verify(headlineGenerator, times(1)).getImagesHeadline(2) - verifyPreviewHeadline(previewView, IMAGE_HEADLINE) - verifyPreviewMetadata(previewView, testMetadataText) + verifyPreviewHeadline(headlineRow, IMAGE_HEADLINE) + verifyPreviewMetadata(headlineRow, testMetadataText) } } @Test - fun test_displayImagesWithoutUriMetadataExternalHeader_showImagesHeadline() { - testLoadingExternalHeadline("image/*", files = null) { externalHeaderView -> - verify(headlineGenerator, times(1)).getImagesHeadline(2) - verifyPreviewHeadline(externalHeaderView, IMAGE_HEADLINE) - verifyPreviewMetadata(externalHeaderView, testMetadataText) - } - } - - @Test - fun test_displayVideosWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("video/*", files = null) { previewView -> + fun test_displayVideosWithoutUriMetadataHeader_showImagesHeadline() { + testLoadingHeadline("video/*", files = null) { headlineRow -> verify(headlineGenerator, times(1)).getVideosHeadline(2) - verifyPreviewHeadline(previewView, VIDEO_HEADLINE) - verifyPreviewMetadata(previewView, testMetadataText) - } - } - - @Test - fun test_displayVideosWithoutUriMetadataExternalHeader_showImagesHeadline() { - testLoadingExternalHeadline("video/*", files = null) { externalHeaderView -> - verify(headlineGenerator, times(1)).getVideosHeadline(2) - verifyPreviewHeadline(externalHeaderView, VIDEO_HEADLINE) - verifyPreviewMetadata(externalHeaderView, testMetadataText) - } - } - - @Test - fun test_displayDocumentsWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("application/pdf", files = null) { previewView -> - verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(previewView, FILES_HEADLINE) - verifyPreviewMetadata(previewView, testMetadataText) - } - } - - @Test - fun test_displayDocumentsWithoutUriMetadataExternalHeader_showImagesHeadline() { - testLoadingExternalHeadline("application/pdf", files = null) { externalHeaderView -> - verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(externalHeaderView, FILES_HEADLINE) - verifyPreviewMetadata(externalHeaderView, testMetadataText) + verifyPreviewHeadline(headlineRow, VIDEO_HEADLINE) + verifyPreviewMetadata(headlineRow, testMetadataText) } } @Test - fun test_displayMixedContentWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("*/*", files = null) { previewView -> + fun test_displayDocumentsWithoutUriMetadataHeader_showImagesHeadline() { + testLoadingHeadline("application/pdf", files = null) { headlineRow -> verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(previewView, FILES_HEADLINE) - verifyPreviewMetadata(previewView, testMetadataText) + verifyPreviewHeadline(headlineRow, FILES_HEADLINE) + verifyPreviewMetadata(headlineRow, testMetadataText) } } @Test - fun test_displayMixedContentWithoutUriMetadataExternalHeader_showImagesHeadline() { - testLoadingExternalHeadline("*/*", files = null) { externalHeader -> + fun test_displayMixedContentWithoutUriMetadataHeader_showImagesHeadline() { + testLoadingHeadline("*/*", files = null) { headlineRow -> verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(externalHeader, FILES_HEADLINE) - verifyPreviewMetadata(externalHeader, testMetadataText) - } - } - - @Test - fun test_displayImagesWithUriMetadataSet_showImagesHeadline() { - val uri = Uri.parse("content://pkg.app/image.png") - val files = - listOf( - FileInfo.Builder(uri).withMimeType("image/png").build(), - FileInfo.Builder(uri).withMimeType("image/jpeg").build(), - ) - testLoadingHeadline("image/*", files) { preivewView -> - verify(headlineGenerator, times(1)).getImagesHeadline(2) - verifyPreviewHeadline(preivewView, IMAGE_HEADLINE) + verifyPreviewHeadline(headlineRow, FILES_HEADLINE) + verifyPreviewMetadata(headlineRow, testMetadataText) } } @Test - fun test_displayImagesWithUriMetadataSetExternalHeader_showImagesHeadline() { + fun test_displayImagesWithUriMetadataSetHeader_showImagesHeadline() { val uri = Uri.parse("content://pkg.app/image.png") val files = listOf( FileInfo.Builder(uri).withMimeType("image/png").build(), FileInfo.Builder(uri).withMimeType("image/jpeg").build(), ) - testLoadingExternalHeadline("image/*", files) { externalHeader -> + testLoadingHeadline("image/*", files) { headlineRow -> verify(headlineGenerator, times(1)).getImagesHeadline(2) - verifyPreviewHeadline(externalHeader, IMAGE_HEADLINE) - } - } - - @Test - fun test_displayVideosWithUriMetadataSet_showImagesHeadline() { - val uri = Uri.parse("content://pkg.app/image.png") - val files = - listOf( - FileInfo.Builder(uri).withMimeType("video/mp4").build(), - FileInfo.Builder(uri).withMimeType("video/mp4").build(), - ) - testLoadingHeadline("video/*", files) { previewView -> - verify(headlineGenerator, times(1)).getVideosHeadline(2) - verifyPreviewHeadline(previewView, VIDEO_HEADLINE) + verifyPreviewHeadline(headlineRow, IMAGE_HEADLINE) } } @Test - fun test_displayVideosWithUriMetadataSetExternalHeader_showImagesHeadline() { + fun test_displayVideosWithUriMetadataSetHeader_showImagesHeadline() { val uri = Uri.parse("content://pkg.app/image.png") val files = listOf( FileInfo.Builder(uri).withMimeType("video/mp4").build(), FileInfo.Builder(uri).withMimeType("video/mp4").build(), ) - testLoadingExternalHeadline("video/*", files) { externalHeader -> + testLoadingHeadline("video/*", files) { headlineRow -> verify(headlineGenerator, times(1)).getVideosHeadline(2) - verifyPreviewHeadline(externalHeader, VIDEO_HEADLINE) - } - } - - @Test - fun test_displayImagesAndVideosWithUriMetadataSet_showImagesHeadline() { - val uri = Uri.parse("content://pkg.app/image.png") - val files = - listOf( - FileInfo.Builder(uri).withMimeType("image/png").build(), - FileInfo.Builder(uri).withMimeType("video/mp4").build(), - ) - testLoadingHeadline("*/*", files) { previewView -> - verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(previewView, FILES_HEADLINE) + verifyPreviewHeadline(headlineRow, VIDEO_HEADLINE) } } @Test - fun test_displayImagesAndVideosWithUriMetadataSetExternalHeader_showImagesHeadline() { + fun test_displayImagesAndVideosWithUriMetadataSetHeader_showImagesHeadline() { val uri = Uri.parse("content://pkg.app/image.png") val files = listOf( FileInfo.Builder(uri).withMimeType("image/png").build(), FileInfo.Builder(uri).withMimeType("video/mp4").build(), ) - testLoadingExternalHeadline("*/*", files) { externalHeader -> + testLoadingHeadline("*/*", files) { headlineRow -> verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + verifyPreviewHeadline(headlineRow, FILES_HEADLINE) } } @Test - fun test_displayDocumentsWithUriMetadataSet_showImagesHeadline() { + fun test_displayDocumentsWithUriMetadataSetHeader_showImagesHeadline() { val uri = Uri.parse("content://pkg.app/image.png") val files = listOf( FileInfo.Builder(uri).withMimeType("application/pdf").build(), FileInfo.Builder(uri).withMimeType("application/pdf").build(), ) - testLoadingHeadline("application/pdf", files) { previewView -> + testLoadingHeadline("application/pdf", files) { headlineRow -> verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(previewView, FILES_HEADLINE) - } - } - - @Test - fun test_displayDocumentsWithUriMetadataSetExternalHeader_showImagesHeadline() { - val uri = Uri.parse("content://pkg.app/image.png") - val files = - listOf( - FileInfo.Builder(uri).withMimeType("application/pdf").build(), - FileInfo.Builder(uri).withMimeType("application/pdf").build(), - ) - testLoadingExternalHeadline("application/pdf", files) { externalHeader -> - verify(headlineGenerator, times(1)).getFilesHeadline(2) - verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + verifyPreviewHeadline(headlineRow, FILES_HEADLINE) } } private fun testLoadingHeadline( intentMimeType: String, files: List<FileInfo>?, - verificationBlock: (ViewGroup?) -> Unit, - ) { - testScope.runTest { - val endMarker = FileInfo.Builder(Uri.EMPTY).build() - val emptySourceFlow = MutableSharedFlow<FileInfo>(replay = 1) - val testSubject = - UnifiedContentPreviewUi( - testScope, - /*isSingleImage=*/ false, - intentMimeType, - actionFactory, - imageLoader, - DefaultMimeTypeClassifier, - object : TransitionElementStatusCallback { - override fun onTransitionElementReady(name: String) = Unit - override fun onAllTransitionElementsReady() = Unit - }, - files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker }, - /*itemCount=*/ 2, - headlineGenerator, - testMetadataText, - ) - val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup - - val previewView = - testSubject.display( - context.resources, - LayoutInflater.from(context), - gridLayout, - /*headlineViewParent=*/ null - ) - emptySourceFlow.tryEmit(endMarker) - - verificationBlock(previewView) - } - } - - private fun testLoadingExternalHeadline( - intentMimeType: String, - files: List<FileInfo>?, verificationBlock: (View?) -> Unit, ) { testScope.runTest { @@ -320,26 +189,20 @@ class UnifiedContentPreviewUiTest { val gridLayout = layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) as ViewGroup - val externalHeaderView = - gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) - assertWithMessage("External headline should not be inflated by default") - .that(externalHeaderView.findViewById<View>(R.id.headline)) + assertWithMessage("Headline row should not be inflated by default") + .that(headlineRow.findViewById<View>(R.id.headline)) .isNull() - val previewView = - testSubject.display( - context.resources, - LayoutInflater.from(context), - gridLayout, - externalHeaderView, - ) - + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + headlineRow, + ) emptySourceFlow.tryEmit(endMarker) - - verifyInternalHeadlineAbsence(previewView) - verifyInternalMetadataAbsence(previewView) - verificationBlock(externalHeaderView) + verificationBlock(headlineRow) } } @@ -361,21 +224,4 @@ class UnifiedContentPreviewUiTest { private fun verifyPreviewMetadata(headerViewParent: View?, expectedText: CharSequence) { verifyTextViewText(headerViewParent, R.id.metadata, expectedText) } - - private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { - assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() - assertWithMessage( - "Preview headline should not be inflated when an external headline is used" - ) - .that(previewView?.findViewById<View>(R.id.headline)) - .isNull() - } - private fun verifyInternalMetadataAbsence(previewView: ViewGroup?) { - assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() - assertWithMessage( - "Preview metadata should not be inflated when an external metadata is used" - ) - .that(previewView?.findViewById<View>(R.id.metadata)) - .isNull() - } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt index f7bf33fd..74f1e59d 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt @@ -21,13 +21,13 @@ import android.database.MatrixCursor import android.media.MediaMetadata import android.net.Uri import android.provider.DocumentsContract -import com.android.intentresolver.any -import com.android.intentresolver.anyOrNull -import com.android.intentresolver.eq -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertWithMessage import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever class UriMetadataReaderTest { private val uri = Uri.parse("content://org.pkg.app/item") @@ -37,7 +37,7 @@ class UriMetadataReaderTest { fun testImageUri() { val mimeType = "image/png" whenever(contentResolver.getType(uri)).thenReturn(mimeType) - val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier) testSubject.getMetadata(uri).let { fileInfo -> assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) @@ -52,7 +52,7 @@ class UriMetadataReaderTest { val imageType = "image/png" whenever(contentResolver.getType(uri)).thenReturn(mimeType) whenever(contentResolver.getStreamTypes(eq(uri), any())).thenReturn(arrayOf(imageType)) - val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier) testSubject.getMetadata(uri).let { fileInfo -> assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) @@ -72,7 +72,7 @@ class UriMetadataReaderTest { addRow(arrayOf(DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL)) } ) - val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier) testSubject.getMetadata(uri).let { fileInfo -> assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) @@ -89,7 +89,7 @@ class UriMetadataReaderTest { val columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI) whenever(contentResolver.query(eq(uri), eq(columns), anyOrNull(), anyOrNull())) .thenReturn(MatrixCursor(columns).apply { addRow(arrayOf(previewUri.toString())) }) - val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + val testSubject = UriMetadataReaderImpl(contentResolver, DefaultMimeTypeClassifier) testSubject.getMetadata(uri).let { fileInfo -> assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt new file mode 100644 index 00000000..9eaee233 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor + +import android.content.ContentInterface +import android.content.Intent +import android.database.MatrixCursor +import android.net.Uri +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH +import android.service.chooser.AdditionalContentContract.Columns.URI +import android.util.Size +import com.android.intentresolver.util.cursor.get +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock + +class PayloadToggleCursorResolverTest { + private val cursorUri = Uri.parse("content://org.pkg.app.extra") + private val chooserIntent = Intent() + + @Test + fun missingSizeColumns() = runTest { + val uri = createUri(1) + val sourceCursor = + MatrixCursor(arrayOf(URI)).apply { + addRow(arrayOf(uri.toString())) + addRow( + arrayOf( + cursorUri.buildUpon().appendPath("should-be-ignored.png").build().toString() + ) + ) + addRow(arrayOf(null)) + } + val fakeContentProvider = + mock<ContentInterface> { + on { query(eq(cursorUri), any(), any(), any()) } doReturn sourceCursor + } + val testSubject = + PayloadToggleCursorResolver( + fakeContentProvider, + cursorUri, + chooserIntent, + ) + + val cursor = testSubject.getCursor() + assertThat(cursor).isNotNull() + assertThat(cursor!!.count).isEqualTo(3) + cursor[0].let { row -> + assertThat(row).isNotNull() + assertThat(row!!.uri).isEqualTo(uri) + assertThat(row.previewSize).isNull() + } + assertThat(cursor[1]).isNull() + assertThat(cursor[2]).isNull() + } + + @Test + fun testCorrectSizeValues() = runTest { + val uri = createUri(1) + val sourceCursor = + MatrixCursor(arrayOf(URI, WIDTH, HEIGHT)).apply { + addRow(arrayOf(uri.toString(), "100", "50")) + } + val fakeContentProvider = + mock<ContentInterface> { + on { query(eq(cursorUri), any(), any(), any()) } doReturn sourceCursor + } + val testSubject = + PayloadToggleCursorResolver( + fakeContentProvider, + cursorUri, + chooserIntent, + ) + + val cursor = testSubject.getCursor() + assertThat(cursor).isNotNull() + assertThat(cursor!!.count).isEqualTo(1) + + cursor[0].let { row -> + assertThat(row).isNotNull() + assertThat(row!!.uri).isEqualTo(uri) + assertThat(row.previewSize).isEqualTo(Size(100, 50)) + } + } +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg/app/img-$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt index b589f566..7c36ef55 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifierImplTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.contentpreview +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent import android.content.Intent import android.content.Intent.ACTION_SEND @@ -24,21 +24,22 @@ import android.net.Uri import com.google.common.truth.Truth.assertThat import org.junit.Test -class TargetIntentModifierTest { +class TargetIntentModifierImplTest { @Test fun testIntentActionChange() { - val testSubject = TargetIntentModifier<Uri>(Intent(ACTION_SEND), { this }, { "image/png" }) + val testSubject = + TargetIntentModifierImpl<Uri>(Intent(ACTION_SEND), { this }, { "image/png" }) val u1 = createUri(1) val u2 = createUri(2) - testSubject.onSelectionChanged(listOf(u1, u2)).let { intent -> + testSubject.intentFromSelection(listOf(u1, u2)).let { intent -> assertThat(intent.action).isEqualTo(ACTION_SEND_MULTIPLE) assertThat(intent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java)) .containsExactly(u1, u2) .inOrder() } - testSubject.onSelectionChanged(listOf(u1)).let { intent -> + testSubject.intentFromSelection(listOf(u1)).let { intent -> assertThat(intent.action).isEqualTo(ACTION_SEND) assertThat(intent.getParcelableExtra(EXTRA_STREAM, Uri::class.java)).isEqualTo(u1) } @@ -47,24 +48,26 @@ class TargetIntentModifierTest { @Test fun testMimeTypeChange() { val testSubject = - TargetIntentModifier<Pair<Uri, String?>>(Intent(ACTION_SEND), { first }, { second }) + TargetIntentModifierImpl<Pair<Uri, String?>>(Intent(ACTION_SEND), { first }, { second }) val u1 = createUri(1) val u2 = createUri(2) - testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "image/png")).let { intent -> + testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "image/png")).let { intent + -> assertThat(intent.type).isEqualTo("image/png") } - testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "image/jpg")).let { intent -> + testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "image/jpg")).let { intent + -> assertThat(intent.type).isEqualTo("image/*") } - testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to "video/mpeg")).let { intent + testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to "video/mpeg")).let { intent -> assertThat(intent.type).isEqualTo("*/*") } - testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to null)).let { intent -> + testSubject.intentFromSelection(listOf(u1 to "image/png", u2 to null)).let { intent -> assertThat(intent.type).isEqualTo("*/*") } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt new file mode 100644 index 00000000..45e456e0 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.database.MatrixCursor +import android.net.Uri +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH +import android.util.Size +import androidx.core.os.bundleOf +import com.android.intentresolver.contentpreview.FileInfo +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.readSize +import com.android.intentresolver.contentpreview.uriMetadataReader +import com.android.intentresolver.util.KosmosTestScope +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.viewBy +import com.android.intentresolver.util.runTest +import com.android.systemui.kosmos.Kosmos +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import org.junit.Test + +class CursorPreviewsInteractorTest { + + private fun runTestWithDeps( + initialSelection: Iterable<Int> = (1..2), + focusedItemIndex: Int = initialSelection.count() / 2, + cursor: Iterable<Int> = (0 until 4), + cursorStartPosition: Int = cursor.count() / 2, + pageSize: Int = 16, + maxLoadedPages: Int = 3, + cursorSizes: Map<Int, Size> = emptyMap(), + metadatSizes: Map<Int, Size> = emptyMap(), + block: KosmosTestScope.(TestDeps) -> Unit, + ) { + val metadataUriToSize = metadatSizes.mapKeys { uri(it.key) } + with(Kosmos()) { + this.focusedItemIndex = focusedItemIndex + this.pageSize = pageSize + this.maxLoadedPages = maxLoadedPages + uriMetadataReader = + object : UriMetadataReader { + override fun getMetadata(uri: Uri): FileInfo = + FileInfo.Builder(uri) + .withPreviewUri(uri) + .withMimeType("image/bitmap") + .build() + + override fun readPreviewSize(uri: Uri): Size? = metadataUriToSize[uri] + } + runTest { + block( + TestDeps( + initialSelection, + cursor, + cursorStartPosition, + cursorSizes, + ) + ) + } + } + } + + private class TestDeps( + initialSelectionRange: Iterable<Int>, + private val cursorRange: Iterable<Int>, + private val cursorStartPosition: Int, + private val cursorSizes: Map<Int, Size>, + ) { + val cursor: CursorView<CursorRow?> = + MatrixCursor(arrayOf("uri", WIDTH, HEIGHT)) + .apply { + extras = bundleOf("position" to cursorStartPosition) + for (i in cursorRange) { + val size = cursorSizes[i] + addRow( + arrayOf( + uri(i).toString(), + size?.width?.toString(), + size?.height?.toString(), + ) + ) + } + } + .viewBy { getString(0)?.let { uriStr -> CursorRow(Uri.parse(uriStr), readSize()) } } + val initialPreviews: List<PreviewModel> = + initialSelectionRange.map { i -> PreviewModel(uri = uri(i), mimeType = "image/bitmap") } + } + + @Test + fun initialCursorLoad() = + runTestWithDeps( + cursor = (0 until 10), + cursorStartPosition = 2, + cursorSizes = mapOf(0 to (200 x 100)), + metadatSizes = mapOf(0 to (300 x 100), 3 to (400 x 100)), + pageSize = 2, + maxLoadedPages = 3, + ) { deps -> + backgroundScope.launch { + cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) + } + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + with(cursorPreviewsRepository.previewsModel.value!!) { + assertThat(previewModels) + .containsExactlyElementsIn( + List(6) { + PreviewModel( + uri = Uri.fromParts("scheme$it", "ssp$it", "fragment$it"), + mimeType = "image/bitmap", + aspectRatio = + when (it) { + 0 -> 2f + 3 -> 4f + else -> 1f + } + ) + } + ) + .inOrder() + assertThat(startIdx).isEqualTo(0) + assertThat(loadMoreLeft).isNull() + assertThat(loadMoreRight).isNotNull() + } + } + + @Test + fun loadMoreLeft_evictRight() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 1, + ) { deps -> + backgroundScope.launch { + cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) + } + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNotNull() + + cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft!!.invoke() + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme15", "ssp15", "fragment15")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() + } + + @Test + fun loadMoreRight_evictLeft() = + runTestWithDeps( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 1, + ) { deps -> + backgroundScope.launch { + cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) + } + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNotNull() + + cursorPreviewsRepository.previewsModel.value!!.loadMoreRight!!.invoke() + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme32", "ssp32", "fragment32")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47")) + } + + @Test + fun noMoreRight_appendUnclaimedFromInitialSelection() = + runTestWithDeps( + initialSelection = listOf(24, 50), + cursor = listOf(24), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { + cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) + } + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(2) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme50", "ssp50", "fragment50")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNull() + } + + @Test + fun noMoreLeft_appendUnclaimedFromInitialSelection() = + runTestWithDeps( + initialSelection = listOf(0, 24), + cursor = listOf(24), + pageSize = 16, + maxLoadedPages = 2, + ) { deps -> + backgroundScope.launch { + cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) + } + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(2) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() + } +} + +private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index") + +private infix fun Int.x(height: Int) = Size(this, height) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt new file mode 100644 index 00000000..2bbda0cc --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractorTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.activityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.data.repository.ChooserRequestRepository +import com.android.intentresolver.data.repository.chooserRequestRepository +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.util.comparingElementsUsingTransform +import com.android.intentresolver.util.runKosmosTest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import org.junit.Test + +class CustomActionsInteractorTest { + + @Test + fun customActions_initialRepoValue() = runKosmosTest { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + val icon = Icon.createWithBitmap(bitmap) + chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest(targetIntent = Intent(), launchedFromPackage = "pkg"), + initialActions = + listOf( + CustomActionModel(label = "label1", icon = icon, performAction = {}), + ), + ) + val underTest = customActionsInteractor + val customActions: StateFlow<List<ActionModel>> = + underTest.customActions.stateIn(backgroundScope) + assertThat(customActions.value) + .comparingElementsUsingTransform("has a label of") { model: ActionModel -> model.label } + .containsExactly("label1") + .inOrder() + assertThat(customActions.value) + .comparingElementsUsingTransform("has an icon of") { model: ActionModel -> model.icon } + .containsExactly(BitmapIcon(icon.bitmap)) + .inOrder() + } + + @Test + fun customActions_tracksRepoUpdates() = runKosmosTest { + val underTest = customActionsInteractor + + val customActions: StateFlow<List<ActionModel>> = + underTest.customActions.stateIn(backgroundScope) + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + val icon = Icon.createWithBitmap(bitmap) + val chooserActions = listOf(CustomActionModel("label1", icon) {}) + chooserRequestRepository.customActions.value = chooserActions + runCurrent() + + assertThat(customActions.value) + .comparingElementsUsingTransform("has a label of") { model: ActionModel -> model.label } + .containsExactly("label1") + .inOrder() + assertThat(customActions.value) + .comparingElementsUsingTransform("has an icon of") { model: ActionModel -> model.icon } + .containsExactly(BitmapIcon(icon.bitmap)) + .inOrder() + } + + @Test + fun customActions_performAction_sendsPendingIntent() = runKosmosTest { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + val icon = Icon.createWithBitmap(bitmap) + var actionSent = false + chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest(targetIntent = Intent(), launchedFromPackage = "pkg"), + initialActions = + listOf( + CustomActionModel( + label = "label1", + icon = icon, + performAction = { actionSent = true }, + ) + ), + ) + val underTest = customActionsInteractor + + val customActions: StateFlow<List<ActionModel>> = + underTest.customActions.stateIn(backgroundScope) + + assertThat(customActions.value).hasSize(1) + + customActions.value[0].performAction(123) + + assertThat(actionSent).isTrue() + assertThat(activityResultRepository.activityResult.value).isEqualTo(Activity.RESULT_OK) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt new file mode 100644 index 00000000..146892d0 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.database.MatrixCursor +import android.net.Uri +import android.util.Size +import androidx.core.os.bundleOf +import com.android.intentresolver.contentpreview.FileInfo +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.payloadToggleCursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.contentpreview.uriMetadataReader +import com.android.intentresolver.inject.contentUris +import com.android.intentresolver.util.KosmosTestScope +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.viewBy +import com.android.intentresolver.util.runTest as runKosmosTest +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.Kosmos.Fixture +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.junit.Test + +class FetchPreviewsInteractorTest { + + private fun runTest( + initialSelection: Iterable<Int> = (1..2), + focusedItemIndex: Int = initialSelection.count() / 2, + cursor: Iterable<Int> = (0 until 4), + cursorStartPosition: Int = cursor.count() / 2, + pageSize: Int = 16, + maxLoadedPages: Int = 8, + previewSizes: Map<Int, Size> = emptyMap(), + block: KosmosTestScope.() -> Unit, + ) { + val previewUriToSize = previewSizes.mapKeys { uri(it.key) } + with(Kosmos()) { + fakeCursorResolver = + FakeCursorResolver(cursorRange = cursor, cursorStartPosition = cursorStartPosition) + payloadToggleCursorResolver = fakeCursorResolver + contentUris = initialSelection.map { uri(it) } + this.focusedItemIndex = focusedItemIndex + uriMetadataReader = + object : UriMetadataReader { + override fun getMetadata(uri: Uri): FileInfo = + FileInfo.Builder(uri) + .withPreviewUri(uri) + .withMimeType("image/bitmap") + .build() + + override fun readPreviewSize(uri: Uri): Size? = previewUriToSize[uri] + } + this.pageSize = pageSize + this.maxLoadedPages = maxLoadedPages + runKosmosTest { block() } + } + } + + private var Kosmos.fakeCursorResolver: FakeCursorResolver by Fixture() + + private class FakeCursorResolver( + private val cursorRange: Iterable<Int>, + private val cursorStartPosition: Int, + ) : CursorResolver<CursorRow?> { + private val mutex = Mutex(locked = true) + + fun complete() = mutex.unlock() + + override suspend fun getCursor(): CursorView<CursorRow?> = + mutex.withLock { + MatrixCursor(arrayOf("uri")) + .apply { + extras = bundleOf("position" to cursorStartPosition) + for (i in cursorRange) { + newRow().add("uri", uri(i).toString()) + } + } + .viewBy { getString(0)?.let(Uri::parse)?.let { CursorRow(it, null) } } + } + } + + @Test + fun setsInitialPreviews() = + runTest(previewSizes = mapOf(1 to Size(100, 50))) { + backgroundScope.launch { fetchPreviewsInteractor.activate() } + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value) + .isEqualTo( + PreviewsModel( + previewModels = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/bitmap", + aspectRatio = 2f + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap", + ), + ), + startIdx = 1, + loadMoreLeft = null, + loadMoreRight = null, + ) + ) + } + + @Test + fun lookupCursorFromContentResolver() = runTest { + backgroundScope.launch { fetchPreviewsInteractor.activate() } + fakeCursorResolver.complete() + runCurrent() + + with(cursorPreviewsRepository) { + assertThat(previewsModel.value).isNotNull() + assertThat(previewsModel.value!!.startIdx).isEqualTo(0) + assertThat(previewsModel.value!!.loadMoreLeft).isNull() + assertThat(previewsModel.value!!.loadMoreRight).isNull() + assertThat(previewsModel.value!!.previewModels) + .containsExactly( + PreviewModel( + uri = Uri.fromParts("scheme0", "ssp0", "fragment0"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), + mimeType = "image/bitmap" + ), + ) + .inOrder() + } + } + + @Test + fun loadMoreLeft_evictRight() = + runTest( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 1, + ) { + backgroundScope.launch { fetchPreviewsInteractor.activate() } + fakeCursorResolver.complete() + runCurrent() + + with(cursorPreviewsRepository) { + assertThat(previewsModel.value).isNotNull() + assertThat(previewsModel.value!!.previewModels).hasSize(16) + assertThat(previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(previewsModel.value!!.loadMoreLeft).isNotNull() + } + + cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft!!.invoke() + runCurrent() + + with(cursorPreviewsRepository) { + assertThat(previewsModel.value).isNotNull() + assertThat(previewsModel.value!!.previewModels).hasSize(16) + assertThat(previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme15", "ssp15", "fragment15")) + assertThat(previewsModel.value!!.loadMoreLeft).isNull() + } + } + + @Test + fun loadMoreRight_evictLeft() = + runTest( + initialSelection = listOf(24), + cursor = (0 until 48), + pageSize = 16, + maxLoadedPages = 1, + ) { + backgroundScope.launch { fetchPreviewsInteractor.activate() } + fakeCursorResolver.complete() + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme16", "ssp16", "fragment16")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme31", "ssp31", "fragment31")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNotNull() + + cursorPreviewsRepository.previewsModel.value!!.loadMoreRight!!.invoke() + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(16) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme32", "ssp32", "fragment32")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme47", "ssp47", "fragment47")) + } + + @Test + fun noMoreRight_appendUnclaimedFromInitialSelection() = + runTest( + initialSelection = listOf(24, 50), + cursor = listOf(24), + pageSize = 16, + maxLoadedPages = 2, + ) { + backgroundScope.launch { fetchPreviewsInteractor.activate() } + fakeCursorResolver.complete() + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(2) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme50", "ssp50", "fragment50")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreRight).isNull() + } + + @Test + fun noMoreLeft_appendUnclaimedFromInitialSelection() = + runTest( + initialSelection = listOf(0, 24), + cursor = listOf(24), + pageSize = 16, + maxLoadedPages = 2, + ) { + backgroundScope.launch { fetchPreviewsInteractor.activate() } + fakeCursorResolver.complete() + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels).hasSize(2) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.first().uri) + .isEqualTo(Uri.fromParts("scheme0", "ssp0", "fragment0")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels.last().uri) + .isEqualTo(Uri.fromParts("scheme24", "ssp24", "fragment24")) + assertThat(cursorPreviewsRepository.previewsModel.value!!.loadMoreLeft).isNull() + } +} + +private fun uri(index: Int) = Uri.fromParts("scheme$index", "ssp$index", "fragment$index") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt new file mode 100644 index 00000000..a3c65570 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.pendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.data.repository.chooserRequestRepository +import com.android.intentresolver.util.runKosmosTest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import org.junit.Test + +class SelectablePreviewInteractorTest { + + @Test + fun reflectPreviewRepo_initState() = runKosmosTest { + targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } + val underTest = + SelectablePreviewInteractor( + key = + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), + selectionInteractor = selectionInteractor, + ) + runCurrent() + + assertThat(underTest.isSelected.first()).isFalse() + } + + @Test + fun reflectPreviewRepo_updatedState() = runKosmosTest { + targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } + val underTest = + SelectablePreviewInteractor( + key = + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ), + selectionInteractor = selectionInteractor, + ) + + assertThat(underTest.isSelected.first()).isFalse() + + previewSelectionsRepository.selections.value = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ) + ) + runCurrent() + + assertThat(underTest.isSelected.first()).isTrue() + } + + @Test + fun setSelected_updatesChooserRequestRepo() = runKosmosTest { + val modifiedIntent = Intent() + targetIntentModifier = TargetIntentModifier { modifiedIntent } + val underTest = + SelectablePreviewInteractor( + key = + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ), + selectionInteractor = selectionInteractor, + ) + + underTest.setSelected(true) + runCurrent() + + assertThat(previewSelectionsRepository.selections.value) + .containsExactly( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ) + ) + + assertThat(chooserRequestRepository.chooserRequest.value.targetIntent) + .isSameInstanceAs(modifiedIntent) + assertThat(pendingSelectionCallbackRepository.pendingTargetIntent.value) + .isSameInstanceAs(modifiedIntent) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt new file mode 100644 index 00000000..be5ddfe5 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.util.runKosmosTest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import org.junit.Test + +class SelectablePreviewsInteractorTest { + + @Test + fun keySet_reflectsRepositoryInit() = runKosmosTest { + cursorPreviewsRepository.previewsModel.value = + PreviewsModel( + previewModels = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap", + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap", + ), + ), + startIdx = 0, + loadMoreLeft = null, + loadMoreRight = null, + ) + previewSelectionsRepository.selections.value = + setOf( + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), + ) + targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } + val underTest = selectablePreviewsInteractor + val keySet = underTest.previews.stateIn(backgroundScope) + + assertThat(keySet.value).isNotNull() + assertThat(keySet.value!!.previewModels) + .containsExactly( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap" + ), + ) + .inOrder() + assertThat(keySet.value!!.startIdx).isEqualTo(0) + assertThat(keySet.value!!.loadMoreLeft).isNull() + assertThat(keySet.value!!.loadMoreRight).isNull() + + val firstModel = + underTest.preview( + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + ) + assertThat(firstModel.isSelected.first()).isTrue() + + val secondModel = + underTest.preview( + PreviewModel(uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = null) + ) + assertThat(secondModel.isSelected.first()).isFalse() + } + + @Test + fun keySet_reflectsRepositoryUpdate() = runKosmosTest { + previewSelectionsRepository.selections.value = + setOf( + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null), + ) + targetIntentModifier = TargetIntentModifier { error("unexpected invocation") } + val underTest = selectablePreviewsInteractor + + val previews = underTest.previews.stateIn(backgroundScope) + val firstModel = + underTest.preview( + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + ) + + assertThat(previews.value).isNull() + assertThat(firstModel.isSelected.first()).isTrue() + + var loadRequested = false + + cursorPreviewsRepository.previewsModel.value = + PreviewsModel( + previewModels = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap", + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap", + ), + ), + startIdx = 5, + loadMoreLeft = null, + loadMoreRight = { loadRequested = true }, + ) + previewSelectionsRepository.selections.value = emptySet() + runCurrent() + + assertThat(previews.value).isNotNull() + assertThat(previews.value!!.previewModels) + .containsExactly( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/bitmap" + ), + PreviewModel( + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap" + ), + ) + .inOrder() + assertThat(previews.value!!.startIdx).isEqualTo(5) + assertThat(previews.value!!.loadMoreLeft).isNull() + assertThat(previews.value!!.loadMoreRight).isNotNull() + + assertThat(firstModel.isSelected.first()).isFalse() + + previews.value!!.loadMoreRight!!.invoke() + + assertThat(loadRequested).isTrue() + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt new file mode 100644 index 00000000..708e6cc6 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import android.net.Uri +import com.android.intentresolver.contentpreview.mimetypeClassifier +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.util.runKosmosTest +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SelectionInteractorTest { + @Test + fun singleSelection_removalPrevented() = runKosmosTest { + val initialPreview = + PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + previewSelectionsRepository.selections.value = setOf(initialPreview) + + val underTest = + SelectionInteractor( + previewSelectionsRepository, + { Intent() }, + updateTargetIntentInteractor, + mimetypeClassifier, + ) + + assertThat(underTest.selections.value).isEqualTo(setOf(initialPreview)) + + // Shouldn't do anything! + underTest.unselect(initialPreview) + + assertThat(underTest.selections.value).isEqualTo(setOf(initialPreview)) + } + + @Test + fun multipleSelections_removalAllowed() = runKosmosTest { + val first = PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null) + val second = + PreviewModel(uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = null) + previewSelectionsRepository.selections.value = setOf(first, second) + + val underTest = + SelectionInteractor( + previewSelectionsRepository, + { Intent() }, + updateTargetIntentInteractor, + mimetypeClassifier + ) + + underTest.unselect(first) + + assertThat(underTest.selections.value).isEqualTo(setOf(second)) + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt new file mode 100644 index 00000000..5aac7b55 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.util.runKosmosTest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import org.junit.Test + +class SetCursorPreviewsInteractorTest { + @Test + fun setPreviews_noAdditionalData() = runKosmosTest { + val loadState = + setCursorPreviewsInteractor.setPreviews( + previewsByKey = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + ) + ), + startIndex = 100, + hasMoreLeft = false, + hasMoreRight = false, + ) + + assertThat(loadState.first()).isNull() + cursorPreviewsRepository.previewsModel.value.let { + assertThat(it).isNotNull() + it!! + assertThat(it.loadMoreRight).isNull() + assertThat(it.loadMoreLeft).isNull() + assertThat(it.startIdx).isEqualTo(100) + assertThat(it.previewModels) + .containsExactly( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + ) + ) + .inOrder() + } + } + + @Test + fun setPreviews_additionalData() = runKosmosTest { + val loadState = + setCursorPreviewsInteractor + .setPreviews( + previewsByKey = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + ) + ), + startIndex = 100, + hasMoreLeft = true, + hasMoreRight = true, + ) + .stateIn(backgroundScope) + + assertThat(loadState.value).isNull() + cursorPreviewsRepository.previewsModel.value.let { + assertThat(it).isNotNull() + it!! + assertThat(it.loadMoreRight).isNotNull() + assertThat(it.loadMoreLeft).isNotNull() + + it.loadMoreRight!!.invoke() + runCurrent() + assertThat(loadState.value).isEqualTo(LoadDirection.Right) + + it.loadMoreLeft!!.invoke() + runCurrent() + assertThat(loadState.value).isEqualTo(LoadDirection.Left) + } + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt new file mode 100644 index 00000000..570c346c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.pendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback +import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.selectionChangeCallback +import com.android.intentresolver.data.repository.chooserRequestRepository +import com.android.intentresolver.util.runKosmosTest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import org.junit.Test + +class UpdateChooserRequestInteractorTest { + @Test + fun updateTargetIntentWithSelection() = runKosmosTest { + val selectionCallbackResult = ShareouselUpdate(metadataText = ValueUpdate.Value("update")) + selectionChangeCallback = SelectionChangeCallback { selectionCallbackResult } + + backgroundScope.launch { processTargetIntentUpdatesInteractor.activate() } + + updateTargetIntentInteractor.updateTargetIntent(Intent()) + runCurrent() + + assertThat(pendingSelectionCallbackRepository.pendingTargetIntent.value).isNull() + assertThat(chooserRequestRepository.chooserRequest.value.metadataText).isEqualTo("update") + } +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt index 40f2ab26..91bbd151 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.contentpreview +package com.android.intentresolver.contentpreview.payloadtoggle.domain.update import android.app.PendingIntent import android.content.ComponentName @@ -27,8 +27,10 @@ import android.content.Intent.EXTRA_ALTERNATE_INTENTS import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER import android.content.Intent.EXTRA_CHOOSER_TARGETS import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT import android.content.Intent.EXTRA_STREAM import android.graphics.drawable.Icon import android.net.Uri @@ -36,32 +38,44 @@ import android.os.Bundle import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget +import android.service.chooser.Flags import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.any -import com.android.intentresolver.argumentCaptor -import com.android.intentresolver.capture -import com.android.intentresolver.mock -import com.android.intentresolver.whenever +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate.Absent +import com.android.intentresolver.inject.FakeChooserServiceFlags import com.google.common.truth.Correspondence import com.google.common.truth.Correspondence.BinaryPredicate import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage +import java.lang.IllegalArgumentException +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.times -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.capture +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) -class SelectionChangeCallbackTest { +class SelectionChangeCallbackImplTest { private val uri = Uri.parse("content://org.pkg/content-provider") private val chooserIntent = Intent(ACTION_CHOOSER) private val contentResolver = mock<ContentInterface>() private val context = InstrumentationRegistry.getInstrumentation().context + private val flags = + FakeChooserServiceFlags().apply { + setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) + setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, false) + setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) + } @Test - fun testPayloadChangeCallbackContact() { - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + fun testPayloadChangeCallbackContact() = runTest { + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val u1 = createUri(1) val u2 = createUri(2) @@ -83,25 +97,24 @@ class SelectionChangeCallbackTest { val extraCaptor = argumentCaptor<Bundle>() verify(contentResolver, times(1)) .call( - capture(authorityCaptor), - capture(methodCaptor), - capture(argCaptor), - capture(extraCaptor) + authorityCaptor.capture(), + methodCaptor.capture(), + argCaptor.capture(), + extraCaptor.capture() ) assertWithMessage("Wrong additional content provider authority") - .that(authorityCaptor.value) + .that(authorityCaptor.firstValue) .isEqualTo(uri.authority) assertWithMessage("Wrong additional content provider #call() method name") - .that(methodCaptor.value) + .that(methodCaptor.firstValue) .isEqualTo(ON_SELECTION_CHANGED) assertWithMessage("Wrong additional content provider argument value") - .that(argCaptor.value) + .that(argCaptor.firstValue) .isEqualTo(uri.toString()) - val extraBundle = extraCaptor.value + val extraBundle = extraCaptor.firstValue assertWithMessage("Additional content provider #call() should have a non-null extras arg.") .that(extraBundle) .isNotNull() - requireNotNull(extraBundle) val argChooserIntent = extraBundle.getParcelable(EXTRA_INTENT, Intent::class.java) assertWithMessage("#call() extras arg. should contain Intent#EXTRA_INTENT") .that(argChooserIntent) @@ -127,7 +140,7 @@ class SelectionChangeCallbackTest { } @Test - fun testPayloadChangeCallbackUpdatesCustomActions() { + fun testPayloadChangeCallbackUpdatesCustomActions() = runTest { val a1 = ChooserAction.Builder( Icon.createWithContentUri(createUri(10)), @@ -157,25 +170,27 @@ class SelectionChangeCallbackTest { Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND_MULTIPLE) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) assertWithMessage("Unexpected custom actions") - .that(result.customActions?.map { it.icon to it.label }) + .that(result.customActions.getOrThrow().map { it.icon to it.label }) .containsExactly(a1.icon to a1.label, a2.icon to a2.label) .inOrder() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackUpdatesReselectionAction() { + fun testPayloadChangeCallbackUpdatesReselectionAction() = runTest { val modifyShare = ChooserAction.Builder( Icon.createWithContentUri(createUri(10)), @@ -193,27 +208,29 @@ class SelectionChangeCallbackTest { Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) assertWithMessage("Unexpected modify share action: wrong icon") - .that(result.modifyShareAction?.icon) + .that(result.modifyShareAction.getOrThrow()?.icon) .isEqualTo(modifyShare.icon) assertWithMessage("Unexpected modify share action: wrong label") - .that(result.modifyShareAction?.label) + .that(result.modifyShareAction.getOrThrow()?.label) .isEqualTo(modifyShare.label) - assertThat(result.customActions).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackUpdatesAlternateIntents() { + fun testPayloadChangeCallbackUpdatesAlternateIntents() = runTest { val alternateIntents = arrayOf( Intent(ACTION_SEND_MULTIPLE).apply { @@ -226,33 +243,35 @@ class SelectionChangeCallbackTest { Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) assertWithMessage("Wrong number of alternate intents") - .that(result.alternateIntents) + .that(result.alternateIntents.getOrThrow()) .hasSize(1) assertWithMessage("Wrong alternate intent: action") - .that(result.alternateIntents?.get(0)?.action) + .that(result.alternateIntents.getOrThrow()[0].action) .isEqualTo(alternateIntents[0].action) assertWithMessage("Wrong alternate intent: categories") - .that(result.alternateIntents?.get(0)?.categories) + .that(result.alternateIntents.getOrThrow()[0].categories) .containsExactlyElementsIn(alternateIntents[0].categories) assertWithMessage("Wrong alternate intent: mime type") - .that(result.alternateIntents?.get(0)?.type) + .that(result.alternateIntents.getOrThrow()[0].type) .isEqualTo(alternateIntents[0].type) - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackUpdatesCallerTargets() { + fun testPayloadChangeCallbackUpdatesCallerTargets() = runTest { val t1 = ChooserTarget( "Target 1", @@ -274,14 +293,14 @@ class SelectionChangeCallbackTest { Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) assertWithMessage("Wrong caller targets") - .that(result.callerTargets) + .that(result.callerTargets.getOrThrow()) .comparingElementsUsing( Correspondence.from( BinaryPredicate<ChooserTarget?, ChooserTarget> { actual, expected -> @@ -296,14 +315,16 @@ class SelectionChangeCallbackTest { .containsExactly(t1, t2) .inOrder() - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackUpdatesRefinementIntentSender() { + fun testPayloadChangeCallbackUpdatesRefinementIntentSender() = runTest { val broadcast = PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE) @@ -314,21 +335,94 @@ class SelectionChangeCallbackTest { } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNotNull() + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender.getOrThrow()).isNotNull() + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) } @Test - fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() { + fun testPayloadChangeCallbackUpdatesResultIntentSender() = runTest { + val broadcast = + PendingIntent.getBroadcast(context, 1, Intent("test"), PendingIntent.FLAG_IMMUTABLE) + + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn( + Bundle().apply { + putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, broadcast.intentSender) + } + ) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender.getOrThrow()).isNotNull() + assertThat(result.metadataText).isEqualTo(Absent) + } + + @Test + fun testPayloadChangeCallbackUpdatesMetadataTextWithDisabledFlag_noUpdates() = runTest { + val metadataText = "[Metadata]" + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) }) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) + } + + @Test + fun testPayloadChangeCallbackUpdatesMetadataTextWithEnabledFlag_valueUpdated() = runTest { + val metadataText = "[Metadata]" + flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) }) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText.getOrThrow()).isEqualTo(metadataText) + } + + @Test + fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() = runTest { + flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) whenever(contentResolver.call(any<String>(), any(), any(), any())) .thenReturn( Bundle().apply { @@ -337,21 +431,31 @@ class SelectionChangeCallbackTest { putParcelableArrayList(EXTRA_ALTERNATE_INTENTS, ArrayList<Intent>()) putParcelableArrayList(EXTRA_CHOOSER_TARGETS, ArrayList<ChooserTarget>()) putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, createUri(2)) + putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, createUri(1)) + putInt(EXTRA_METADATA_TEXT, 123) } ) - val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) assertWithMessage("Callback result should not be null").that(result).isNotNull() requireNotNull(result) - assertThat(result.customActions).isNull() - assertThat(result.modifyShareAction).isNull() - assertThat(result.alternateIntents).isNull() - assertThat(result.callerTargets).isNull() - assertThat(result.refinementIntentSender).isNull() + assertThat(result.customActions.getOrThrow()).isEmpty() + assertThat(result.modifyShareAction.getOrThrow()).isNull() + assertThat(result.alternateIntents.getOrThrow()).isEmpty() + assertThat(result.callerTargets.getOrThrow()).isEmpty() + assertThat(result.refinementIntentSender.getOrThrow()).isNull() + assertThat(result.resultIntentSender.getOrThrow()).isNull() + assertThat(result.metadataText.getOrThrow()).isNull() } } +private fun <T> ValueUpdate<T>.getOrThrow(): T = + when (this) { + is ValueUpdate.Value -> value + else -> throw IllegalArgumentException("Value is expected") + } + private fun createUri(id: Int) = Uri.parse("content://org.pkg.images/$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt new file mode 100644 index 00000000..fb3e9a3f --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt @@ -0,0 +1,285 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Icon +import android.net.Uri +import com.android.intentresolver.FakeImageLoader +import com.android.intentresolver.contentpreview.HeadlineGenerator +import com.android.intentresolver.contentpreview.mimetypeClassifier +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.activityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.pendingIntentSender +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.chooserRequestInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.customActionsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.headlineGenerator +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.payloadToggleImageLoader +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectablePreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.data.repository.chooserRequestRepository +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.logging.FakeEventLog +import com.android.intentresolver.logging.eventLog +import com.android.intentresolver.util.KosmosTestScope +import com.android.intentresolver.util.comparingElementsUsingTransform +import com.android.intentresolver.util.runKosmosTest +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.Kosmos.Fixture +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import org.junit.Test + +class ShareouselViewModelTest { + + private var Kosmos.viewModelScope: CoroutineScope by Fixture() + private val Kosmos.shareouselViewModel: ShareouselViewModel by Fixture { + ShareouselViewModelModule.create( + interactor = selectablePreviewsInteractor, + imageLoader = payloadToggleImageLoader, + actionsInteractor = customActionsInteractor, + headlineGenerator = headlineGenerator, + chooserRequestInteractor = chooserRequestInteractor, + mimeTypeClassifier = mimetypeClassifier, + selectionInteractor = selectionInteractor, + scope = viewModelScope, + ) + } + + @Test + fun headline_images() = runTest { + assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 1") + previewSelectionsRepository.selections.value = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/png", + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/jpeg", + ) + ) + runCurrent() + assertThat(shareouselViewModel.headline.first()).isEqualTo("IMAGES: 2") + } + + @Test + fun headline_videos() = runTest { + previewSelectionsRepository.selections.value = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "video/mpeg", + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg", + ) + ) + runCurrent() + assertThat(shareouselViewModel.headline.first()).isEqualTo("VIDEOS: 2") + } + + @Test + fun headline_mixed() = runTest { + previewSelectionsRepository.selections.value = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/jpeg", + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg", + ) + ) + runCurrent() + assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 2") + } + + @Test + fun metadataText() = runTest { + val request = + ChooserRequest( + targetIntent = Intent(), + launchedFromPackage = "", + metadataText = "Hello" + ) + chooserRequestRepository.chooserRequest.value = request + + runCurrent() + + assertThat(shareouselViewModel.metadataText.first()).isEqualTo("Hello") + } + + @Test + fun previews() = + runTest(targetIntentModifier = { Intent() }) { + cursorPreviewsRepository.previewsModel.value = + PreviewsModel( + previewModels = + setOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/png", + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg", + ) + ), + startIdx = 1, + loadMoreLeft = null, + loadMoreRight = null, + ) + runCurrent() + + assertWithMessage("previewsKeys is null") + .that(shareouselViewModel.previews.first()) + .isNotNull() + assertThat(shareouselViewModel.previews.first()!!.previewModels) + .comparingElementsUsingTransform("has uri of") { it: PreviewModel -> it.uri } + .containsExactly( + Uri.fromParts("scheme", "ssp", "fragment"), + Uri.fromParts("scheme1", "ssp1", "fragment1"), + ) + .inOrder() + + val previewVm = + shareouselViewModel.preview( + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg" + ) + ) + + assertWithMessage("preview bitmap is null").that(previewVm.bitmap.first()).isNotNull() + assertThat(previewVm.isSelected.first()).isFalse() + assertThat(previewVm.contentType).isEqualTo(ContentType.Video) + + previewVm.setSelected(true) + + assertThat(previewSelectionsRepository.selections.value) + .comparingElementsUsingTransform("has uri of") { model: PreviewModel -> model.uri } + .contains(Uri.fromParts("scheme1", "ssp1", "fragment1")) + } + + @Test + fun actions() { + runTest { + assertThat(shareouselViewModel.actions.first()).isEmpty() + + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + val icon = Icon.createWithBitmap(bitmap) + var actionSent = false + chooserRequestRepository.customActions.value = + listOf( + CustomActionModel( + label = "label1", + icon = icon, + performAction = { actionSent = true }, + ) + ) + runCurrent() + + assertThat(shareouselViewModel.actions.first()) + .comparingElementsUsingTransform("has a label of") { vm: ActionChipViewModel -> + vm.label + } + .containsExactly("label1") + .inOrder() + assertThat(shareouselViewModel.actions.first()) + .comparingElementsUsingTransform("has an icon of") { vm: ActionChipViewModel -> + vm.icon + } + .containsExactly(BitmapIcon(icon.bitmap)) + .inOrder() + + shareouselViewModel.actions.first()[0].onClicked() + + assertThat(actionSent).isTrue() + assertThat(eventLog.customActionSelected) + .isEqualTo(FakeEventLog.CustomActionSelected(0)) + assertThat(activityResultRepository.activityResult.value).isEqualTo(Activity.RESULT_OK) + } + } + + private fun runTest( + pendingIntentSender: PendingIntentSender = PendingIntentSender {}, + targetIntentModifier: TargetIntentModifier<PreviewModel> = TargetIntentModifier { + error("unexpected invocation") + }, + block: suspend KosmosTestScope.() -> Unit, + ): Unit = runKosmosTest { + viewModelScope = backgroundScope + this.pendingIntentSender = pendingIntentSender + this.targetIntentModifier = targetIntentModifier + previewSelectionsRepository.selections.value = + setOf(PreviewModel(uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null)) + payloadToggleImageLoader = + FakeImageLoader( + initialBitmaps = + mapOf( + Uri.fromParts("scheme1", "ssp1", "fragment1") to + Bitmap.createBitmap(100, 100, Bitmap.Config.ALPHA_8) + ) + ) + headlineGenerator = + object : HeadlineGenerator { + override fun getImagesHeadline(count: Int): String = "IMAGES: $count" + + override fun getTextHeadline(text: CharSequence): String = error("not supported") + + override fun getAlbumHeadline(): String = error("not supported") + + override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String = + error("not supported") + + override fun getVideosWithTextHeadline(text: CharSequence, count: Int): String = + error("not supported") + + override fun getFilesWithTextHeadline(text: CharSequence, count: Int): String = + error("not supported") + + override fun getVideosHeadline(count: Int): String = "VIDEOS: $count" + + override fun getFilesHeadline(count: Int): String = "FILES: $count" + } + // instantiate the view model, and then runCurrent() so that it is fully hydrated before + // starting the test + shareouselViewModel + runCurrent() + block() + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/coroutines/Flow.kt b/tests/unit/src/com/android/intentresolver/coroutines/Flow.kt index a5677d94..ca60824d 100644 --- a/tests/unit/src/com/android/intentresolver/v2/coroutines/Flow.kt +++ b/tests/unit/src/com/android/intentresolver/coroutines/Flow.kt @@ -1,6 +1,6 @@ @file:Suppress("OPT_IN_USAGE") -package com.android.intentresolver.v2.coroutines +package com.android.intentresolver.coroutines /* * Copyright (C) 2022 The Android Open Source Project diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt b/tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt index d10ea8d0..2fad37f2 100644 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/data/repository/FakeUserRepositoryTest.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package com.android.intentresolver.v2.data.repository +package com.android.intentresolver.data.repository -import com.android.intentresolver.v2.coroutines.collectLastValue -import com.android.intentresolver.v2.shared.model.User +import com.android.intentresolver.coroutines.collectLastValue +import com.android.intentresolver.shared.model.User import com.google.common.truth.Truth.assertThat import kotlin.random.Random import kotlinx.coroutines.test.runTest diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt index 16e8c9bb..8db0bb56 100644 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/data/repository/UserRepositoryImplTest.kt @@ -1,18 +1,31 @@ -package com.android.intentresolver.v2.data.repository +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository -import android.content.Intent import android.content.pm.UserInfo import android.os.UserHandle import android.os.UserHandle.SYSTEM import android.os.UserHandle.USER_SYSTEM import android.os.UserManager -import com.android.intentresolver.mock -import com.android.intentresolver.v2.coroutines.collectLastValue -import com.android.intentresolver.v2.platform.FakeUserManager -import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType -import com.android.intentresolver.v2.shared.model.User -import com.android.intentresolver.v2.shared.model.User.Role -import com.android.intentresolver.whenever +import com.android.intentresolver.coroutines.collectLastValue +import com.android.intentresolver.platform.FakeUserManager +import com.android.intentresolver.platform.FakeUserManager.ProfileType +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlinx.coroutines.Dispatchers @@ -20,8 +33,9 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test -import org.mockito.Mockito -import org.mockito.Mockito.doReturn +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock internal class UserRepositoryImplTest { private val userManager = FakeUserManager() @@ -43,9 +57,10 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users!!.filter { it.role.type == User.Type.PROFILE }).isEmpty() + assertThat(users).hasSize(1) val profile = userState.createProfile(ProfileType.WORK) + assertThat(users).hasSize(2) assertThat(users).contains(User(profile.identifier, Role.WORK)) } @@ -89,11 +104,11 @@ internal class UserRepositoryImplTest { repo.requestState(privateUser, false) repo.requestState(privateUser, true) - assertWithMessage("users.size") - .that(users?.size ?: 0).isEqualTo(2) // personal + private + assertWithMessage("users.size").that(users?.size ?: 0).isEqualTo(2) // personal + private assertWithMessage("No duplicate IDs") - .that(users?.count { it.id == private.identifier }).isEqualTo(1) + .that(users?.count { it.id == private.identifier }) + .isEqualTo(1) } @Test @@ -112,13 +127,6 @@ internal class UserRepositoryImplTest { assertThat(available?.get(workUser)).isTrue() } - @Test(expected = IllegalArgumentException::class) - fun requestState_invalidForFullUser() = runTest { - val repo = createUserRepository(userManager) - val primaryUser = User(userState.primaryUserHandle.identifier, Role.PERSONAL) - repo.requestState(primaryUser, available = false) - } - /** * This and all the 'recovers_from_*' tests below all configure a static event flow instead of * using [FakeUserManager]. These tests verify that a invalid broadcast causes the flow to @@ -128,13 +136,7 @@ internal class UserRepositoryImplTest { fun recovers_from_invalid_profile_added_event() = runTest { val userManager = mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = - flowOf( - UserRepositoryImpl.UserEvent( - Intent.ACTION_PROFILE_ADDED, - UserHandle.of(UserHandle.USER_NULL) - ) - ) + val events = flowOf(ProfileAdded(UserHandle.of(UserHandle.USER_NULL))) val repo = UserRepositoryImpl( profileParent = SYSTEM, @@ -153,13 +155,7 @@ internal class UserRepositoryImplTest { fun recovers_from_invalid_profile_removed_event() = runTest { val userManager = mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = - flowOf( - UserRepositoryImpl.UserEvent( - Intent.ACTION_PROFILE_REMOVED, - UserHandle.of(UserHandle.USER_NULL) - ) - ) + val events = flowOf(ProfileRemoved(UserHandle.of(UserHandle.USER_NULL))) val repo = UserRepositoryImpl( profileParent = SYSTEM, @@ -178,13 +174,7 @@ internal class UserRepositoryImplTest { fun recovers_from_invalid_profile_available_event() = runTest { val userManager = mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = - flowOf( - UserRepositoryImpl.UserEvent( - Intent.ACTION_PROFILE_AVAILABLE, - UserHandle.of(UserHandle.USER_NULL) - ) - ) + val events = flowOf(AvailabilityChange(UserHandle.of(UserHandle.USER_NULL))) val repo = UserRepositoryImpl(SYSTEM, userManager, events, backgroundScope, Dispatchers.Unconfined) val users by collectLastValue(repo.users) @@ -197,10 +187,7 @@ internal class UserRepositoryImplTest { fun recovers_from_unknown_event() = runTest { val userManager = mockUserManager(validUser = USER_SYSTEM, invalidUser = UserHandle.USER_NULL) - val events = - flowOf( - UserRepositoryImpl.UserEvent("UNKNOWN_EVENT", UserHandle.of(UserHandle.USER_NULL)) - ) + val events = flowOf(UnknownEvent("UNKNOWN_EVENT")) val repo = UserRepositoryImpl( profileParent = SYSTEM, @@ -216,24 +203,22 @@ internal class UserRepositoryImplTest { } } -@Suppress("SameParameterValue", "DEPRECATION") +@Suppress("SameParameterValue") private fun mockUserManager(validUser: Int, invalidUser: Int) = mock<UserManager> { val info = UserInfo(validUser, "", "", UserInfo.FLAG_FULL) - doReturn(listOf(info)).whenever(this).getEnabledProfiles(Mockito.anyInt()) - - doReturn(info).whenever(this).getUserInfo(Mockito.eq(validUser)) - - doReturn(listOf<UserInfo>()).whenever(this).getEnabledProfiles(Mockito.eq(invalidUser)) - - doReturn(null).whenever(this).getUserInfo(Mockito.eq(invalidUser)) + on { getEnabledProfiles(any()) } doReturn listOf(info) + on { getUserInfo(validUser) } doReturn info + on { getEnabledProfiles(invalidUser) } doReturn listOf() + on { getUserInfo(invalidUser) } doReturn null } -private fun TestScope.createUserRepository(userManager: FakeUserManager) = - UserRepositoryImpl( +private fun TestScope.createUserRepository(userManager: FakeUserManager): UserRepositoryImpl { + return UserRepositoryImpl( profileParent = userManager.state.primaryUserHandle, userManager = userManager, userEvents = userManager.state.userEvents, scope = backgroundScope, backgroundDispatcher = Dispatchers.Unconfined ) +} diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt b/tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt index a81a315b..4d6f2e5b 100644 --- a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/domain/interactor/UserInteractorTest.kt @@ -14,16 +14,16 @@ * limitations under the License. */ -package com.android.intentresolver.v2.domain.interactor - -import com.android.intentresolver.v2.coroutines.collectLastValue -import com.android.intentresolver.v2.data.repository.FakeUserRepository -import com.android.intentresolver.v2.shared.model.Profile -import com.android.intentresolver.v2.shared.model.Profile.Type.PERSONAL -import com.android.intentresolver.v2.shared.model.Profile.Type.PRIVATE -import com.android.intentresolver.v2.shared.model.Profile.Type.WORK -import com.android.intentresolver.v2.shared.model.User -import com.android.intentresolver.v2.shared.model.User.Role +package com.android.intentresolver.domain.interactor + +import com.android.intentresolver.coroutines.collectLastValue +import com.android.intentresolver.data.repository.FakeUserRepository +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.Profile.Type.PERSONAL +import com.android.intentresolver.shared.model.Profile.Type.PRIVATE +import com.android.intentresolver.shared.model.Profile.Type.WORK +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlin.random.Random @@ -150,8 +150,7 @@ class UserInteractorTest { val userRepo = FakeUserRepository(listOf(personalUser)) userRepo.addUser(workUser, false) - val interactor = - UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + val interactor = UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) val availability by collectLastValue(interactor.availability) @@ -162,8 +161,7 @@ class UserInteractorTest { @Test fun isAvailable() = runTest { val userRepo = FakeUserRepository(listOf(workUser, personalUser)) - val interactor = - UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + val interactor = UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) val availability by collectLastValue(interactor.availability) diff --git a/tests/unit/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt index 4c05dfb1..084d12e6 100644 --- a/tests/unit/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt +++ b/tests/unit/src/com/android/intentresolver/emptystate/CompositeEmptyStateProviderTest.kt @@ -17,9 +17,9 @@ package com.android.intentresolver.emptystate import com.android.intentresolver.ResolverListAdapter -import com.android.intentresolver.mock import com.google.common.truth.Truth.assertThat import org.junit.Test +import org.mockito.kotlin.mock class CompositeEmptyStateProviderTest { val listAdapter = mock<ResolverListAdapter>() diff --git a/tests/unit/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt index 2bcddf59..8cf87ebe 100644 --- a/tests/unit/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt +++ b/tests/unit/src/com/android/intentresolver/emptystate/CrossProfileIntentsCheckerTest.kt @@ -19,14 +19,14 @@ package com.android.intentresolver.emptystate import android.content.ContentResolver import android.content.Intent import android.content.pm.IPackageManager -import com.android.intentresolver.mock -import com.android.intentresolver.whenever import com.google.common.truth.Truth.assertThat import org.junit.Test import org.mockito.Mockito.any import org.mockito.Mockito.anyInt import org.mockito.Mockito.eq import org.mockito.Mockito.nullable +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock class CrossProfileIntentsCheckerTest { private val PERSONAL_USER_ID = 10 @@ -38,15 +38,14 @@ class CrossProfileIntentsCheckerTest { fun testChecker_hasCrossProfileIntents() { val packageManager = mock<IPackageManager> { - whenever( - canForwardTo( - any(Intent::class.java), - nullable(String::class.java), - eq(PERSONAL_USER_ID), - eq(WORK_USER_ID) - ) + on { + canForwardTo( + any(Intent::class.java), + nullable(String::class.java), + eq(PERSONAL_USER_ID), + eq(WORK_USER_ID) ) - .thenReturn(true) + } doReturn (true) } val checker = CrossProfileIntentsChecker(contentResolver, packageManager) val intents = listOf(Intent()) @@ -57,15 +56,14 @@ class CrossProfileIntentsCheckerTest { fun testChecker_noCrossProfileIntents() { val packageManager = mock<IPackageManager> { - whenever( - canForwardTo( - any(Intent::class.java), - nullable(String::class.java), - anyInt(), - anyInt() - ) + on { + canForwardTo( + any(Intent::class.java), + nullable(String::class.java), + anyInt(), + anyInt() ) - .thenReturn(false) + } doReturn (false) } val checker = CrossProfileIntentsChecker(contentResolver, packageManager) val intents = listOf(Intent()) diff --git a/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt index bc5545db..174b8d59 100644 --- a/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt +++ b/tests/unit/src/com/android/intentresolver/emptystate/EmptyStateUiHelperTest.kt @@ -20,17 +20,31 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import android.widget.TextView import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat +import java.util.Optional +import java.util.function.Supplier import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify class EmptyStateUiHelperTest { private val context = InstrumentationRegistry.getInstrumentation().getContext() + var shouldOverrideContainerPadding = false + val containerPaddingSupplier = + Supplier<Optional<Int>> { + Optional.ofNullable(if (shouldOverrideContainerPadding) 42 else null) + } + lateinit var rootContainer: ViewGroup - lateinit var emptyStateTitleView: View - lateinit var emptyStateSubtitleView: View + lateinit var mainListView: View // Visible when no empty state is showing. + lateinit var emptyStateTitleView: TextView + lateinit var emptyStateSubtitleView: TextView lateinit var emptyStateButtonView: View lateinit var emptyStateProgressView: View lateinit var emptyStateDefaultTextView: View @@ -47,21 +61,26 @@ class EmptyStateUiHelperTest { rootContainer, true ) + mainListView = rootContainer.requireViewById(com.android.internal.R.id.resolver_list) emptyStateRootView = rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state) emptyStateTitleView = rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - emptyStateSubtitleView = rootContainer.requireViewById( - com.android.internal.R.id.resolver_empty_state_subtitle) - emptyStateButtonView = rootContainer.requireViewById( - com.android.internal.R.id.resolver_empty_state_button) - emptyStateProgressView = rootContainer.requireViewById( - com.android.internal.R.id.resolver_empty_state_progress) - emptyStateDefaultTextView = - rootContainer.requireViewById(com.android.internal.R.id.empty) - emptyStateContainerView = rootContainer.requireViewById( - com.android.internal.R.id.resolver_empty_state_container) - emptyStateUiHelper = EmptyStateUiHelper(rootContainer) + emptyStateSubtitleView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) + emptyStateButtonView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_button) + emptyStateProgressView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) + emptyStateDefaultTextView = rootContainer.requireViewById(com.android.internal.R.id.empty) + emptyStateContainerView = + rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_container) + emptyStateUiHelper = + EmptyStateUiHelper( + rootContainer, + com.android.internal.R.id.resolver_list, + containerPaddingSupplier + ) } @Test @@ -105,9 +124,104 @@ class EmptyStateUiHelperTest { @Test fun testHide() { emptyStateRootView.visibility = View.VISIBLE + mainListView.visibility = View.GONE emptyStateUiHelper.hide() assertThat(emptyStateRootView.visibility).isEqualTo(View.GONE) + assertThat(mainListView.visibility).isEqualTo(View.VISIBLE) + } + + @Test + fun testBottomPaddingDelegate_default() { + shouldOverrideContainerPadding = false + emptyStateContainerView.setPadding(1, 2, 3, 4) + + emptyStateUiHelper.setupContainerPadding() + + assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) + assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) + assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) + assertThat(emptyStateContainerView.paddingBottom).isEqualTo(4) + } + + @Test + fun testBottomPaddingDelegate_override() { + shouldOverrideContainerPadding = true // Set bottom padding to 42. + emptyStateContainerView.setPadding(1, 2, 3, 4) + + emptyStateUiHelper.setupContainerPadding() + + assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) + assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) + assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) + assertThat(emptyStateContainerView.paddingBottom).isEqualTo(42) + } + + @Test + fun testShowEmptyState_noOnClickHandler() { + mainListView.visibility = View.VISIBLE + + // Note: an `EmptyState.ClickListener` isn't invoked directly by the UI helper; it has to be + // built into the "on-click handler" that's injected to implement the button-press. We won't + // display the button without a click "handler," even if it *does* have a `ClickListener`. + val clickListener = mock<EmptyState.ClickListener>() + + val emptyState = + object : EmptyState { + override fun getTitle() = "Test title" + override fun getSubtitle() = "Test subtitle" + + override fun getButtonClickListener() = clickListener + } + emptyStateUiHelper.showEmptyState(emptyState, null) + + assertThat(mainListView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + + assertThat(emptyStateTitleView.text).isEqualTo("Test title") + assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") + + verify(clickListener, never()).onClick(any()) + } + + @Test + fun testShowEmptyState_withOnClickHandlerAndClickListener() { + mainListView.visibility = View.VISIBLE + + val clickListener = mock<EmptyState.ClickListener>() + val onClickHandler = mock<View.OnClickListener>() + + val emptyState = + object : EmptyState { + override fun getTitle() = "Test title" + override fun getSubtitle() = "Test subtitle" + + override fun getButtonClickListener() = clickListener + } + emptyStateUiHelper.showEmptyState(emptyState, onClickHandler) + + assertThat(mainListView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) + assertThat(emptyStateButtonView.visibility).isEqualTo(View.VISIBLE) // Now shown. + assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) + assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) + + assertThat(emptyStateTitleView.text).isEqualTo("Test title") + assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") + + emptyStateButtonView.performClick() + + verify(onClickHandler).onClick(emptyStateButtonView) + // The test didn't explicitly configure its `OnClickListener` to relay the click event on + // to the `EmptyState.ClickListener`, so it still won't have fired here. + verify(clickListener, never()).onClick(any()) } } diff --git a/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt b/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt new file mode 100644 index 00000000..135ac064 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProviderTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate + +import android.content.Intent +import com.android.intentresolver.ProfileHelper +import com.android.intentresolver.ResolverListAdapter +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.data.repository.DevicePolicyResources +import com.android.intentresolver.data.repository.FakeUserRepository +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.FakeIntentResolverFlags +import com.android.intentresolver.shared.model.User +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.same +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.verification.VerificationMode + +@OptIn(JavaInterop::class) +class NoCrossProfileEmptyStateProviderTest { + + private val personalUser = User(0, User.Role.PERSONAL) + private val workUser = User(10, User.Role.WORK) + private val privateUser = User(11, User.Role.PRIVATE) + private val flags = FakeIntentResolverFlags() + + private val userRepository = FakeUserRepository(listOf(personalUser, workUser, privateUser)) + + private val personalIntents = listOf(Intent("PERSONAL")) + private val personalListAdapter = + mock<ResolverListAdapter> { + on { userHandle } doReturn personalUser.handle + on { intents } doReturn personalIntents + } + private val workIntents = listOf(Intent("WORK")) + private val workListAdapter = + mock<ResolverListAdapter> { + on { userHandle } doReturn workUser.handle + on { intents } doReturn workIntents + } + private val privateIntents = listOf(Intent("PRIVATE")) + private val privateListAdapter = + mock<ResolverListAdapter> { + on { userHandle } doReturn privateUser.handle + on { intents } doReturn privateIntents + } + + private val devicePolicyResources = + mock<DevicePolicyResources> { + on { crossProfileBlocked } doReturn "Cross profile blocked" + on { toPersonalBlockedByPolicyMessage(any()) } doReturn "Blocked to Personal" + on { toWorkBlockedByPolicyMessage(any()) } doReturn "Blocked to Work" + on { toPrivateBlockedByPolicyMessage(any()) } doReturn "Blocked to Private" + } + + // If asked, no intent can ever be forwarded between any pair of users. + private val crossProfileIntentsChecker = + mock<CrossProfileIntentsChecker> { + on { + hasCrossProfileIntents( + /* intents = */ any(), + /* source = */ any(), + /* target = */ any() + ) + } doReturn false /* Never allow */ + } + + @Test + fun verifyTestSetup() { + assertThat(workListAdapter.userHandle).isEqualTo(workUser.handle) + assertThat(personalListAdapter.userHandle).isEqualTo(personalUser.handle) + assertThat(privateListAdapter.userHandle).isEqualTo(privateUser.handle) + } + + @Test + fun sameProfilePermitted() { + val profileHelper = createProfileHelper(launchedAs = workUser) + + val provider = + NoCrossProfileEmptyStateProvider( + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true + ) + + // Work to work, not blocked + assertThat(provider.getEmptyState(workListAdapter)).isNull() + + crossProfileIntentsChecker.verifyCalled(never()) + } + + @Test + fun testPersonalToWork() { + val profileHelper = createProfileHelper(launchedAs = personalUser) + + val provider = + NoCrossProfileEmptyStateProvider( + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true + ) + + val result = provider.getEmptyState(workListAdapter) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo("Cross profile blocked") + assertThat(result?.subtitle).isEqualTo("Blocked to Work") + + crossProfileIntentsChecker.verifyCalled(times(1), workIntents, personalUser, workUser) + } + + @Test + fun testWorkToPersonal() { + val profileHelper = createProfileHelper(launchedAs = workUser) + + val provider = + NoCrossProfileEmptyStateProvider( + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true + ) + + val result = provider.getEmptyState(personalListAdapter) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo("Cross profile blocked") + assertThat(result?.subtitle).isEqualTo("Blocked to Personal") + + crossProfileIntentsChecker.verifyCalled(times(1), personalIntents, workUser, personalUser) + } + + @Test + fun testWorkToPrivate() { + val profileHelper = createProfileHelper(launchedAs = workUser) + + val provider = + NoCrossProfileEmptyStateProvider( + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true + ) + + val result = provider.getEmptyState(privateListAdapter) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo("Cross profile blocked") + assertThat(result?.subtitle).isEqualTo("Blocked to Private") + + // effective target user is personalUser due to "delegate from parent" + crossProfileIntentsChecker.verifyCalled(times(1), privateIntents, workUser, personalUser) + } + + @Test + fun testPrivateToPersonal() { + val profileHelper = createProfileHelper(launchedAs = privateUser) + + val provider = + NoCrossProfileEmptyStateProvider( + profileHelper, + devicePolicyResources, + crossProfileIntentsChecker, + /* isShare = */ true + ) + + // Private -> Personal is always allowed: + // Private delegates to the parent profile for policy; so personal->personal is allowed. + assertThat(provider.getEmptyState(personalListAdapter)).isNull() + + crossProfileIntentsChecker.verifyCalled(never()) + } + + private fun createProfileHelper(launchedAs: User): ProfileHelper { + val userInteractor = UserInteractor(userRepository, launchedAs = launchedAs.handle) + + return ProfileHelper( + userInteractor, + CoroutineScope(Dispatchers.Unconfined), + Dispatchers.Unconfined, + flags + ) + } + + private fun CrossProfileIntentsChecker.verifyCalled( + mode: VerificationMode, + list: List<Intent>? = null, + sourceUser: User? = null, + targetUser: User? = null, + ) { + val sourceUserId = argumentCaptor<Int>() + val targetUserId = argumentCaptor<Int>() + + verify(this, mode) + .hasCrossProfileIntents(same(list), sourceUserId.capture(), targetUserId.capture()) + sourceUser?.apply { + assertWithMessage("hasCrossProfileIntents: source") + .that(sourceUserId.firstValue) + .isEqualTo(id) + } + targetUser?.apply { + assertWithMessage("hasCrossProfileIntents: target") + .that(targetUserId.firstValue) + .isEqualTo(id) + } + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt b/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt index 5eac6bd6..c09047a1 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt +++ b/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ext +package com.android.intentresolver.ext import android.graphics.Point import androidx.core.os.bundleOf diff --git a/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt b/tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt index 2ccd548a..bf1e159c 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt +++ b/tests/unit/src/com/android/intentresolver/ext/IntentExtTest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.ext +package com.android.intentresolver.ext import android.content.ComponentName import android.content.Intent diff --git a/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java b/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java index d75ea99b..feb277ea 100644 --- a/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java +++ b/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java @@ -32,10 +32,10 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import android.content.Intent; import android.metrics.LogMaker; +import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.intentresolver.logging.EventLogImpl.SharesheetStandardEvent; import com.android.intentresolver.logging.EventLogImpl.SharesheetStartedEvent; import com.android.intentresolver.logging.EventLogImpl.SharesheetTargetSelectedEvent; -import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.logging.InstanceId; import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.MetricsLogger; diff --git a/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java index 2140a67d..5cec9734 100644 --- a/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ b/tests/unit/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java @@ -25,7 +25,7 @@ import android.content.pm.ActivityInfo; import android.content.pm.ResolveInfo; import android.os.Message; -import androidx.test.InstrumentationRegistry; +import androidx.test.platform.app.InstrumentationRegistry; import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -47,7 +47,7 @@ public class AbstractResolverComparatorTest { ResolvedComponentInfo r2 = createResolvedComponentInfo( new ComponentName("zackage", "zlass")); - Context context = InstrumentationRegistry.getTargetContext(); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); @@ -64,7 +64,7 @@ public class AbstractResolverComparatorTest { new ComponentName("zackage", "zlass")); r2.setPinned(true); - Context context = InstrumentationRegistry.getTargetContext(); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); AbstractResolverComparator comparator = getTestComparator(context, null); assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); @@ -78,7 +78,7 @@ public class AbstractResolverComparatorTest { ResolvedComponentInfo r2 = createResolvedComponentInfo( new ComponentName("package", "class")); - Context context = InstrumentationRegistry.getTargetContext(); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst); assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2)); @@ -94,7 +94,7 @@ public class AbstractResolverComparatorTest { new ComponentName("package", "class")); r2.setPinned(true); - Context context = InstrumentationRegistry.getTargetContext(); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); AbstractResolverComparator comparator = getTestComparator(context, cementedComponent); assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2)); diff --git a/tests/unit/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt index 04c7093d..fd74b50a 100644 --- a/tests/unit/src/com/android/intentresolver/v2/platform/FakeSecureSettingsTest.kt +++ b/tests/unit/src/com/android/intentresolver/platform/FakeSecureSettingsTest.kt @@ -1,4 +1,20 @@ -package com.android.intentresolver.v2.platform +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform import com.google.common.truth.Truth.assertThat diff --git a/tests/unit/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt b/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt index a2239192..fdc32207 100644 --- a/tests/unit/src/com/android/intentresolver/v2/platform/FakeUserManagerTest.kt +++ b/tests/unit/src/com/android/intentresolver/platform/FakeUserManagerTest.kt @@ -1,10 +1,26 @@ -package com.android.intentresolver.v2.platform +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform import android.content.pm.UserInfo import android.content.pm.UserInfo.NO_PROFILE_GROUP_ID import android.os.UserHandle import android.os.UserManager -import com.android.intentresolver.v2.platform.FakeUserManager.ProfileType +import com.android.intentresolver.platform.FakeUserManager.ProfileType import com.google.common.truth.Correspondence import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage diff --git a/tests/unit/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt index fd5c8b3f..71ef2919 100644 --- a/tests/unit/src/com/android/intentresolver/v2/platform/NearbyShareModuleTest.kt +++ b/tests/unit/src/com/android/intentresolver/platform/NearbyShareModuleTest.kt @@ -1,17 +1,29 @@ -package com.android.intentresolver.v2.platform +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform import android.content.ComponentName import android.content.Context import android.content.res.Configuration import android.provider.Settings import android.testing.TestableResources - import androidx.test.platform.app.InstrumentationRegistry - import com.android.intentresolver.R - import com.google.common.truth.Truth8.assertThat - import org.junit.Before import org.junit.Test @@ -58,8 +70,8 @@ class NearbyShareModuleTest { val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) - assertThat(nearbyShareComponent).hasValue( - ComponentName.unflattenFromString("com.example/.ComponentName")) + assertThat(nearbyShareComponent) + .hasValue(ComponentName.unflattenFromString("com.example/.ComponentName")) } @Test @@ -77,7 +89,7 @@ class NearbyShareModuleTest { val nearbyShareComponent = NearbyShareModule.nearbyShareComponent(resources, secureSettings) - assertThat(nearbyShareComponent).hasValue( - ComponentName.unflattenFromString("com.example/.BComponent")) + assertThat(nearbyShareComponent) + .hasValue(ComponentName.unflattenFromString("com.example/.BComponent")) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt index 5b6b5d99..8eafa0b5 100644 --- a/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/profiles/MultiProfilePagerAdapterTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.profiles +package com.android.intentresolver.profiles import android.os.UserHandle import android.view.LayoutInflater @@ -22,18 +22,18 @@ import android.view.View import android.view.ViewGroup import android.widget.ListView import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_PERSONAL -import com.android.intentresolver.MultiProfilePagerAdapter.PROFILE_WORK import com.android.intentresolver.R import com.android.intentresolver.ResolverListAdapter import com.android.intentresolver.emptystate.EmptyStateProvider -import com.android.intentresolver.mock -import com.android.intentresolver.whenever +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK import com.google.common.collect.ImmutableList import com.google.common.truth.Truth.assertThat import java.util.Optional import java.util.function.Supplier import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock class MultiProfilePagerAdapterTest { private val PERSONAL_USER_HANDLE = UserHandle.of(10) @@ -48,7 +48,7 @@ class MultiProfilePagerAdapterTest { @Test fun testSinglePageProfileAdapter() { val personalListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE } val pagerAdapter = MultiProfilePagerAdapter( { listAdapter: ResolverListAdapter -> listAdapter }, @@ -87,9 +87,9 @@ class MultiProfilePagerAdapterTest { @Test fun testTwoProfilePagerAdapter() { val personalListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE } val workListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + mock<ResolverListAdapter> { on { userHandle } doReturn WORK_USER_HANDLE } val pagerAdapter = MultiProfilePagerAdapter( { listAdapter: ResolverListAdapter -> listAdapter }, @@ -134,9 +134,9 @@ class MultiProfilePagerAdapterTest { @Test fun testTwoProfilePagerAdapter_workIsDefault() { val personalListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE } val workListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) } + mock<ResolverListAdapter> { on { userHandle } doReturn WORK_USER_HANDLE } val pagerAdapter = MultiProfilePagerAdapter( { listAdapter: ResolverListAdapter -> listAdapter }, @@ -179,7 +179,7 @@ class MultiProfilePagerAdapterTest { @Test fun testBottomPaddingDelegate_default() { val personalListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE } val pagerAdapter = MultiProfilePagerAdapter( { listAdapter: ResolverListAdapter -> listAdapter }, @@ -204,9 +204,9 @@ class MultiProfilePagerAdapterTest { { Optional.empty() } ) val container = - pagerAdapter - .getActiveEmptyStateView() - .requireViewById<View>(com.android.internal.R.id.resolver_empty_state_container) + pagerAdapter.activeEmptyStateView.requireViewById<View>( + com.android.internal.R.id.resolver_empty_state_container + ) container.setPadding(1, 2, 3, 4) pagerAdapter.setupContainerPadding() assertThat(container.paddingLeft).isEqualTo(1) @@ -218,7 +218,7 @@ class MultiProfilePagerAdapterTest { @Test fun testBottomPaddingDelegate_override() { val personalListAdapter = - mock<ResolverListAdapter> { whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) } + mock<ResolverListAdapter> { on { userHandle } doReturn PERSONAL_USER_HANDLE } val pagerAdapter = MultiProfilePagerAdapter( { listAdapter: ResolverListAdapter -> listAdapter }, @@ -243,9 +243,9 @@ class MultiProfilePagerAdapterTest { { Optional.of(42) } ) val container = - pagerAdapter - .getActiveEmptyStateView() - .requireViewById<View>(com.android.internal.R.id.resolver_empty_state_container) + pagerAdapter.activeEmptyStateView.requireViewById<View>( + com.android.internal.R.id.resolver_empty_state_container + ) container.setPadding(1, 2, 3, 4) pagerAdapter.setupContainerPadding() assertThat(container.paddingLeft).isEqualTo(1) @@ -261,13 +261,13 @@ class MultiProfilePagerAdapterTest { // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? val personalListAdapter = mock<ResolverListAdapter> { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) + on { userHandle } doReturn PERSONAL_USER_HANDLE + on { unfilteredCount } doReturn 1 } val workListAdapter = mock<ResolverListAdapter> { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) + on { userHandle } doReturn WORK_USER_HANDLE + on { unfilteredCount } doReturn 1 } val pagerAdapter = MultiProfilePagerAdapter( @@ -304,13 +304,13 @@ class MultiProfilePagerAdapterTest { // believe `shouldShowEmptyStateScreen` should be implemented in terms of the provider? val personalListAdapter = mock<ResolverListAdapter> { - whenever(getUserHandle()).thenReturn(PERSONAL_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) + on { userHandle } doReturn PERSONAL_USER_HANDLE + on { unfilteredCount } doReturn 1 } val workListAdapter = mock<ResolverListAdapter> { - whenever(getUserHandle()).thenReturn(WORK_USER_HANDLE) - whenever(getUnfilteredCount()).thenReturn(1) + on { userHandle } doReturn WORK_USER_HANDLE + on { unfilteredCount } doReturn 1 } val pagerAdapter = MultiProfilePagerAdapter( diff --git a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index 4eeae872..fbdc062b 100644 --- a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -27,15 +27,10 @@ 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.argumentCaptor -import com.android.intentresolver.capture 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 com.google.common.truth.Truth.assertWithMessage import java.util.function.Consumer import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -48,11 +43,15 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.atLeastOnce -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) @SmallTest @@ -64,19 +63,19 @@ class ShortcutLoaderTest { } private val pm = mock<PackageManager> { - whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo) + on { getApplicationInfo(any(), any<ApplicationInfoFlags>()) } doReturn appInfo } private val userManager = mock<UserManager> { - whenever(isUserRunning(any<UserHandle>())).thenReturn(true) - whenever(isUserUnlocked(any<UserHandle>())).thenReturn(true) - whenever(isQuietModeEnabled(any<UserHandle>())).thenReturn(false) + on { isUserRunning(any<UserHandle>()) } doReturn true + on { isUserUnlocked(any<UserHandle>()) } doReturn true + on { isQuietModeEnabled(any<UserHandle>()) } doReturn false } private val context = mock<Context> { - whenever(packageManager).thenReturn(pm) - whenever(createContextAsUser(any(), anyInt())).thenReturn(this) - whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) + on { packageManager } doReturn pm + on { createContextAsUser(any(), any()) } doReturn mock + on { getSystemService(Context.USER_SERVICE) } doReturn userManager } private val scheduler = TestCoroutineScheduler() private val dispatcher = UnconfinedTestDispatcher(scheduler) @@ -86,7 +85,7 @@ class ShortcutLoaderTest { private val callback = mock<Consumer<ShortcutLoader.Result>>() private val componentName = ComponentName("pkg", "Class") private val appTarget = - mock<DisplayResolveInfo> { whenever(resolvedComponentName).thenReturn(componentName) } + mock<DisplayResolveInfo> { on { resolvedComponentName } doReturn componentName } private val appTargets = arrayOf(appTarget) private val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) @@ -119,13 +118,13 @@ class ShortcutLoaderTest { ) val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() verify(appPredictor, atLeastOnce()) - .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) - appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + appPredictorCallbackCaptor.firstValue.onTargetsAvailable(shortcuts) val resultCaptor = argumentCaptor<ShortcutLoader.Result>() - verify(callback, times(1)).accept(capture(resultCaptor)) + verify(callback, times(1)).accept(resultCaptor.capture()) - val result = resultCaptor.value + val result = resultCaptor.firstValue assertTrue("An app predictor result is expected", result.isFromAppPredictor) assertArrayEquals( "Wrong input app targets in the result", @@ -159,7 +158,7 @@ class ShortcutLoaderTest { ) val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + on { getShareTargets(intentFilter) } doReturn shortcutManagerResult } whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) val testSubject = @@ -177,9 +176,9 @@ class ShortcutLoaderTest { testSubject.updateAppTargets(appTargets) val resultCaptor = argumentCaptor<ShortcutLoader.Result>() - verify(callback, times(1)).accept(capture(resultCaptor)) + verify(callback, times(1)).accept(resultCaptor.capture()) - val result = resultCaptor.value + val result = resultCaptor.firstValue assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) assertArrayEquals( "Wrong input app targets in the result", @@ -212,7 +211,7 @@ class ShortcutLoaderTest { ) val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + on { getShareTargets(intentFilter) } doReturn (shortcutManagerResult) } whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) val testSubject = @@ -232,13 +231,13 @@ class ShortcutLoaderTest { verify(appPredictor, times(1)).requestPredictionUpdate() val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) - appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + appPredictorCallbackCaptor.firstValue.onTargetsAvailable(emptyList()) val resultCaptor = argumentCaptor<ShortcutLoader.Result>() - verify(callback, times(1)).accept(capture(resultCaptor)) + verify(callback, times(1)).accept(resultCaptor.capture()) - val result = resultCaptor.value + val result = resultCaptor.firstValue assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) assertArrayEquals( "Wrong input app targets in the result", @@ -271,7 +270,7 @@ class ShortcutLoaderTest { ) val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + on { getShareTargets(intentFilter) } doReturn shortcutManagerResult } whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) whenever(appPredictor.requestPredictionUpdate()) @@ -293,9 +292,9 @@ class ShortcutLoaderTest { verify(appPredictor, times(1)).requestPredictionUpdate() val resultCaptor = argumentCaptor<ShortcutLoader.Result>() - verify(callback, times(1)).accept(capture(resultCaptor)) + verify(callback, times(1)).accept(resultCaptor.capture()) - val result = resultCaptor.value + val result = resultCaptor.firstValue assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) assertArrayEquals( "Wrong input app targets in the result", @@ -346,7 +345,7 @@ class ShortcutLoaderTest { ) val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) + on { getShareTargets(intentFilter) } doReturn shortcutManagerResult } whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) val testSubject = @@ -417,14 +416,14 @@ class ShortcutLoaderTest { verify(appPredictor, times(1)).requestPredictionUpdate() val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) - appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + appPredictorCallbackCaptor.firstValue.onTargetsAvailable(emptyList()) verify(shortcutManager, never()).getShareTargets(any()) val resultCaptor = argumentCaptor<ShortcutLoader.Result>() - verify(callback, times(1)).accept(capture(resultCaptor)) + verify(callback, times(1)).accept(resultCaptor.capture()) - val result = resultCaptor.value + val result = resultCaptor.firstValue assertWithMessage("A ShortcutManager result is expected") .that(result.isFromAppPredictor) .isFalse() diff --git a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt index e0de005d..ce6ef477 100644 --- a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt +++ b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt @@ -32,9 +32,9 @@ 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 -> + 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( @@ -54,13 +54,14 @@ class ShortcutToChooserTargetConverterTest { val appTargetCache = HashMap<ChooserTarget, AppTarget>() val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>() - var chooserTargets = testSubject.convertToChooserTarget( - shortcuts, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) + var chooserTargets = + testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) assertCorrectShortcutToChooserTargetConversion( shortcuts, @@ -77,13 +78,14 @@ class ShortcutToChooserTargetConverterTest { appTargetCache.clear() shortcutInfoCache.clear() - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) + chooserTargets = + testSubject.convertToChooserTarget( + subset, + shortcuts, + appTargets, + appTargetCache, + shortcutInfoCache, + ) assertCorrectShortcutToChooserTargetConversion( shortcuts, @@ -102,17 +104,20 @@ class ShortcutToChooserTargetConverterTest { 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, - ) + var chooserTargets = + testSubject.convertToChooserTarget( + shortcuts, + shortcuts, + null, + null, + shortcutInfoCache, + ) assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderAllShortcuts, expectedScoreAllShortcuts + shortcuts, + chooserTargets, + expectedOrderAllShortcuts, + expectedScoreAllShortcuts ) assertShortcutInfoCache(chooserTargets, shortcutInfoCache) @@ -124,17 +129,20 @@ class ShortcutToChooserTargetConverterTest { val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) shortcutInfoCache.clear() - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - null, - null, - shortcutInfoCache, - ) + chooserTargets = + testSubject.convertToChooserTarget( + subset, + shortcuts, + null, + null, + shortcutInfoCache, + ) assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderSubset, expectedScoreSubset + shortcuts, + chooserTargets, + expectedOrderSubset, + expectedScoreSubset ) assertShortcutInfoCache(chooserTargets, shortcutInfoCache) } @@ -158,7 +166,8 @@ class ShortcutToChooserTargetConverterTest { } private fun assertAppTargetCache( - chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, AppTarget> + chooserTargets: List<ChooserTarget>, + cache: Map<ChooserTarget, AppTarget> ) { for (ct in chooserTargets) { val target = cache[ct] @@ -167,7 +176,8 @@ class ShortcutToChooserTargetConverterTest { } private fun assertShortcutInfoCache( - chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, ShortcutInfo> + chooserTargets: List<ChooserTarget>, + cache: Map<ChooserTarget, ShortcutInfo> ) { for (ct in chooserTargets) { val si = cache[ct] diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt b/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt index 371f9c26..c254a856 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ui +package com.android.intentresolver.ui import android.app.PendingIntent import android.compat.testing.PlatformCompatChangeRule @@ -23,9 +23,9 @@ import android.content.Intent import android.os.Process import android.service.chooser.ChooserResult import android.service.chooser.Flags -import androidx.test.InstrumentationRegistry +import androidx.test.platform.app.InstrumentationRegistry import com.android.intentresolver.inject.FakeChooserServiceFlags -import com.android.intentresolver.v2.ui.model.ShareAction +import com.android.intentresolver.ui.model.ShareAction import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlinx.coroutines.CompletableDeferred diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt b/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt index 049fa001..737f02fe 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package com.android.intentresolver.v2.ui.model +package com.android.intentresolver.ui.model import android.content.Intent import android.content.Intent.ACTION_CHOOSER import android.content.Intent.EXTRA_TEXT import android.net.Uri -import com.android.intentresolver.v2.ext.toParcelAndBack +import com.android.intentresolver.ext.toParcelAndBack import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import org.junit.Test diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt index d3b9f559..56c019fd 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.ui.viewmodel +package com.android.intentresolver.ui.viewmodel import android.content.Intent import android.content.Intent.ACTION_CHOOSER @@ -30,13 +30,13 @@ import android.service.chooser.Flags import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.inject.FakeChooserServiceFlags -import com.android.intentresolver.v2.ui.model.ActivityModel -import com.android.intentresolver.v2.ui.model.ChooserRequest -import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -126,10 +126,7 @@ class ChooserRequestTest { fun payloadIntents_includesTargetThenAdditional() { val intent1 = Intent(ACTION_SEND) val intent2 = Intent(ACTION_SEND_MULTIPLE) - val model = createActivityModel( - targetIntent = intent1, - additionalIntents = listOf(intent2) - ) + val model = createActivityModel(targetIntent = intent1, additionalIntents = listOf(intent2)) val result = readChooserRequest(model, fakeChooserServiceFlags) @@ -229,7 +226,8 @@ class ChooserRequestTest { fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val uri = Uri.parse("content://org.pkg/path") val position = 10 - val model = createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply { + val model = + createActivityModel(targetIntent = Intent(ACTION_VIEW)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } @@ -266,7 +264,8 @@ class ChooserRequestTest { fun metadataText_whenFlagFalse_isNull() { fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) val metadataText: CharSequence = "Test metadata text" - val model = createActivityModel(targetIntent = Intent()).apply { + val model = + createActivityModel(targetIntent = Intent()).apply { intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) } @@ -283,7 +282,8 @@ class ChooserRequestTest { // Arrange fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) val metadataText: CharSequence = "Test metadata text" - val model = createActivityModel(targetIntent = Intent()).apply { + val model = + createActivityModel(targetIntent = Intent()).apply { intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) } diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt index 6f1ed853..bd80235d 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2.ui.viewmodel +package com.android.intentresolver.ui.viewmodel import android.content.Intent import android.content.Intent.ACTION_VIEW @@ -21,14 +21,13 @@ import android.net.Uri import android.os.UserHandle import androidx.core.net.toUri import androidx.core.os.bundleOf -import com.android.intentresolver.v2.ResolverActivity.PROFILE_WORK -import com.android.intentresolver.v2.shared.model.Profile.Type.WORK -import com.android.intentresolver.v2.ui.model.ActivityModel -import com.android.intentresolver.v2.ui.model.ChooserRequest -import com.android.intentresolver.v2.ui.model.ResolverRequest -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.UncaughtException -import com.android.intentresolver.v2.validation.Valid +import com.android.intentresolver.ResolverActivity.PROFILE_WORK +import com.android.intentresolver.shared.model.Profile.Type.WORK +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.UncaughtException +import com.android.intentresolver.validation.Valid import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import org.junit.Test diff --git a/tests/unit/src/com/android/intentresolver/util/TestKosmos.kt b/tests/unit/src/com/android/intentresolver/util/TestKosmos.kt new file mode 100644 index 00000000..473d9b72 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/util/TestKosmos.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import com.android.intentresolver.backgroundDispatcher +import com.android.systemui.kosmos.Kosmos +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent + +fun Kosmos.runTest( + dispatcher: TestDispatcher = StandardTestDispatcher(), + block: suspend KosmosTestScope.() -> Unit, +) { + val kosmos = this + backgroundDispatcher = dispatcher + kotlinx.coroutines.test.runTest(dispatcher) { KosmosTestScope(kosmos, this).block() } +} + +fun runKosmosTest( + dispatcher: TestDispatcher = StandardTestDispatcher(), + block: suspend KosmosTestScope.() -> Unit, +) { + Kosmos().runTest(dispatcher, block) +} + +class KosmosTestScope( + kosmos: Kosmos, + private val testScope: TestScope, +) : Kosmos by kosmos { + val backgroundScope + get() = testScope.backgroundScope + + @ExperimentalCoroutinesApi fun runCurrent() = testScope.runCurrent() +} diff --git a/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt b/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt new file mode 100644 index 00000000..b96b6f05 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/util/TruthUtils.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import com.google.common.truth.Correspondence +import com.google.common.truth.IterableSubject + +fun <A, B> IterableSubject.comparingElementsUsingTransform( + description: String, + function: (A) -> B, +): IterableSubject.UsingCorrespondence<A, B> = + comparingElementsUsing(Correspondence.transforming(function, description)) diff --git a/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt b/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt index 18218064..32c19f13 100644 --- a/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt +++ b/tests/unit/src/com/android/intentresolver/util/UriFiltersTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.intentresolver.util import android.app.PendingIntent diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt deleted file mode 100644 index 95e4c377..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2 - -import android.app.Activity -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Context.RECEIVER_EXPORTED -import android.content.Intent -import android.content.IntentFilter -import android.content.res.Resources -import android.graphics.drawable.Icon -import android.service.chooser.ChooserAction -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.logging.EventLog -import com.android.intentresolver.mock -import com.android.intentresolver.v2.ui.ShareResultSender -import com.android.intentresolver.v2.ui.model.ShareAction -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import java.util.Optional -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.function.Consumer -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.eq -import org.mockito.Mockito.eq -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -@RunWith(AndroidJUnit4::class) -class ChooserActionFactoryTest { - private val context = InstrumentationRegistry.getInstrumentation().context - - private val logger = mock<EventLog>() - private val actionLabel = "Action label" - private val modifyShareLabel = "Modify share" - private val testAction = "com.android.intentresolver.testaction" - private val countdown = CountDownLatch(1) - private val testReceiver: BroadcastReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - // Just doing at most a single countdown per test. - countdown.countDown() - } - } - private val resultConsumer = - object : Consumer<Int> { - var latestReturn = Integer.MIN_VALUE - - override fun accept(resultCode: Int) { - latestReturn = resultCode - } - } - - @Before - fun setup() { - context.registerReceiver(testReceiver, IntentFilter(testAction), RECEIVER_EXPORTED) - } - - @After - fun teardown() { - context.unregisterReceiver(testReceiver) - } - - @Test - fun testCreateCustomActions() { - val factory = createFactory() - - val customActions = factory.createCustomActions() - - assertThat(customActions.size).isEqualTo(1) - assertThat(customActions[0].label).isEqualTo(actionLabel) - - // click it - customActions[0].onClicked.run() - - verify(logger).logCustomActionSelected(eq(0)) - assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) - // Verify the pending intent has been called - assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) - } - - @Test - fun testNoModifyShareAction() { - val factory = createFactory(includeModifyShare = false) - - assertThat(factory.modifyShareAction).isNull() - } - - @Test - fun testModifyShareAction() { - val factory = createFactory(includeModifyShare = true) - - val action = factory.modifyShareAction ?: error("Modify share action should not be null") - action.onClicked.run() - - verify(logger).logActionSelected(eq(EventLog.SELECTION_TYPE_MODIFY_SHARE)) - assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) - // Verify the pending intent has been called - assertTrue("Timed out waiting for broadcast", countdown.await(2500, TimeUnit.MILLISECONDS)) - } - - @Test - fun nonSendAction_noCopyRunnable() { - val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE).apply { - putExtra(Intent.EXTRA_TEXT, "Text to show") - } - - val chooserRequest = - mock<ChooserRequestParameters> { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } - val testSubject = - ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ null, - /* finishCallback = */ {}, - /* clipboardManager = */ mock(), - ) - assertThat(testSubject.copyButtonRunnable).isNull() - } - - @Test - fun sendActionNoText_noCopyRunnable() { - val targetIntent = Intent(Intent.ACTION_SEND) - - val chooserRequest = - mock<ChooserRequestParameters> { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } - val testSubject = - ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ null, - /* finishCallback = */ {}, - /* clipboardManager = */ mock(), - ) - assertThat(testSubject.copyButtonRunnable).isNull() - } - - @Test - fun sendActionWithTextCopyRunnable() { - val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") } - - val chooserRequest = - mock<ChooserRequestParameters> { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } - - val resultSender = mock<ShareResultSender>() - val testSubject = - ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ resultSender, - /* finishCallback = */ {}, - /* clipboardManager = */ mock(), - ) - assertThat(testSubject.copyButtonRunnable).isNotNull() - - testSubject.copyButtonRunnable?.run() - - verify(resultSender, times(1)).onActionSelected(ShareAction.SYSTEM_COPY) - } - - private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { - val testPendingIntent = - PendingIntent.getBroadcast(context, 0, Intent(testAction), PendingIntent.FLAG_IMMUTABLE) - val targetIntent = Intent() - val action = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - actionLabel, - testPendingIntent - ) - .build() - val chooserRequest = mock<ChooserRequestParameters>() - whenever(chooserRequest.targetIntent).thenReturn(targetIntent) - whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) - - if (includeModifyShare) { - val modifyShare = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - modifyShareLabel, - testPendingIntent - ) - .build() - whenever(chooserRequest.modifyShareAction).thenReturn(modifyShare) - } - - return ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ null, - /* finishCallback = */ resultConsumer, - /* clipboardManager = */ mock(), - ) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt deleted file mode 100644 index ec2b807d..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2 - -import android.app.PendingIntent -import android.content.Intent -import android.content.res.Resources -import android.graphics.drawable.Icon -import android.service.chooser.ChooserAction -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.logging.EventLog -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertWithMessage -import java.util.Optional -import java.util.function.Consumer -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChooserMutableActionFactoryTest { - private val context - get() = InstrumentationRegistry.getInstrumentation().context - - private val logger = mock<EventLog>() - private val testAction = "com.android.intentresolver.testaction" - private val resultConsumer = - object : Consumer<Int> { - var latestReturn = Integer.MIN_VALUE - - override fun accept(resultCode: Int) { - latestReturn = resultCode - } - } - - private val scope = TestScope() - - @Test - fun testInitialValue() = - scope.runTest { - val actions = createChooserActions(2) - val actionFactory = createFactory(actions) - val testSubject = ChooserMutableActionFactory(actionFactory) - - val createdActions = testSubject.createCustomActions() - val observedActions = testSubject.customActionsFlow.first() - - assertWithMessage("Unexpected actions") - .that(createdActions.map { it.label }) - .containsExactlyElementsIn(actions.map { it.label }) - .inOrder() - assertWithMessage("Initially created and initially observed actions should be the same") - .that(createdActions) - .containsExactlyElementsIn(observedActions) - .inOrder() - } - - @Test - fun testUpdateActions_newActionsPublished() = - scope.runTest { - val initialActions = createChooserActions(2) - val updatedActions = createChooserActions(3) - val actionFactory = createFactory(initialActions) - val testSubject = ChooserMutableActionFactory(actionFactory) - - testSubject.updateCustomActions(updatedActions) - val observedActions = testSubject.customActionsFlow.first() - - assertWithMessage("Unexpected updated actions") - .that(observedActions.map { it.label }) - .containsAtLeastElementsIn(updatedActions.map { it.label }) - .inOrder() - } - - private fun createFactory(actions: List<ChooserAction>): ChooserActionFactory { - val targetIntent = Intent() - val chooserRequest = mock<ChooserRequestParameters>() - whenever(chooserRequest.targetIntent).thenReturn(targetIntent) - whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.copyOf(actions)) - - return ChooserActionFactory( - /* context = */ context, - /* targetIntent = */ chooserRequest.targetIntent, - /* referrerPackageName = */ chooserRequest.referrerPackageName, - /* chooserActions = */ chooserRequest.chooserActions, - /* modifyShareAction = */ chooserRequest.modifyShareAction, - /* imageEditor = */ Optional.empty(), - /* log = */ logger, - /* onUpdateSharedTextIsExcluded = */ {}, - /* firstVisibleImageQuery = */ { null }, - /* activityStarter = */ mock(), - /* shareResultSender = */ null, - /* finishCallback = */ resultConsumer, - mock() - ) - } - - private fun createChooserActions(count: Int): List<ChooserAction> { - return buildList(count) { - for (i in 1..count) { - val testPendingIntent = - PendingIntent.getBroadcast( - context, - i, - Intent(testAction), - PendingIntent.FLAG_IMMUTABLE - ) - val action = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - "Label $i", - testPendingIntent - ) - .build() - add(action) - } - } - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt b/tests/unit/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt deleted file mode 100644 index 696dd1fd..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/emptystate/EmptyStateUiHelperTest.kt +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.emptystate - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.TextView -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.any -import com.android.intentresolver.emptystate.EmptyState -import com.android.intentresolver.mock -import com.google.common.truth.Truth.assertThat -import java.util.Optional -import java.util.function.Supplier -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.verify - -class EmptyStateUiHelperTest { - private val context = InstrumentationRegistry.getInstrumentation().getContext() - - var shouldOverrideContainerPadding = false - val containerPaddingSupplier = - Supplier<Optional<Int>> { - Optional.ofNullable(if (shouldOverrideContainerPadding) 42 else null) - } - - lateinit var rootContainer: ViewGroup - lateinit var mainListView: View // Visible when no empty state is showing. - lateinit var emptyStateTitleView: TextView - lateinit var emptyStateSubtitleView: TextView - lateinit var emptyStateButtonView: View - lateinit var emptyStateProgressView: View - lateinit var emptyStateDefaultTextView: View - lateinit var emptyStateContainerView: View - lateinit var emptyStateRootView: View - lateinit var emptyStateUiHelper: EmptyStateUiHelper - - @Before - fun setup() { - rootContainer = FrameLayout(context) - LayoutInflater.from(context) - .inflate( - com.android.intentresolver.R.layout.resolver_list_per_profile, - rootContainer, - true - ) - mainListView = rootContainer.requireViewById(com.android.internal.R.id.resolver_list) - emptyStateRootView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state) - emptyStateTitleView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_title) - emptyStateSubtitleView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_subtitle) - emptyStateButtonView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_button) - emptyStateProgressView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_progress) - emptyStateDefaultTextView = rootContainer.requireViewById(com.android.internal.R.id.empty) - emptyStateContainerView = - rootContainer.requireViewById(com.android.internal.R.id.resolver_empty_state_container) - emptyStateUiHelper = - EmptyStateUiHelper( - rootContainer, - com.android.internal.R.id.resolver_list, - containerPaddingSupplier - ) - } - - @Test - fun testResetViewVisibilities() { - // First set each view's visibility to differ from the expected "reset" state so we can then - // assert that they're all reset afterward. - // TODO: for historic reasons "reset" doesn't cover `emptyStateContainerView`; should it? - emptyStateRootView.visibility = View.GONE - emptyStateTitleView.visibility = View.GONE - emptyStateSubtitleView.visibility = View.GONE - emptyStateButtonView.visibility = View.VISIBLE - emptyStateProgressView.visibility = View.VISIBLE - emptyStateDefaultTextView.visibility = View.VISIBLE - - emptyStateUiHelper.resetViewVisibilities() - - assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE) - assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) - } - - @Test - fun testShowSpinner() { - emptyStateTitleView.visibility = View.VISIBLE - emptyStateButtonView.visibility = View.VISIBLE - emptyStateProgressView.visibility = View.GONE - emptyStateDefaultTextView.visibility = View.VISIBLE - - emptyStateUiHelper.showSpinner() - - // TODO: should this cover any other views? Subtitle? - assertThat(emptyStateTitleView.visibility).isEqualTo(View.INVISIBLE) - assertThat(emptyStateButtonView.visibility).isEqualTo(View.INVISIBLE) - assertThat(emptyStateProgressView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) - } - - @Test - fun testHide() { - emptyStateRootView.visibility = View.VISIBLE - mainListView.visibility = View.GONE - - emptyStateUiHelper.hide() - - assertThat(emptyStateRootView.visibility).isEqualTo(View.GONE) - assertThat(mainListView.visibility).isEqualTo(View.VISIBLE) - } - - @Test - fun testBottomPaddingDelegate_default() { - shouldOverrideContainerPadding = false - emptyStateContainerView.setPadding(1, 2, 3, 4) - - emptyStateUiHelper.setupContainerPadding() - - assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) - assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) - assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) - assertThat(emptyStateContainerView.paddingBottom).isEqualTo(4) - } - - @Test - fun testBottomPaddingDelegate_override() { - shouldOverrideContainerPadding = true // Set bottom padding to 42. - emptyStateContainerView.setPadding(1, 2, 3, 4) - - emptyStateUiHelper.setupContainerPadding() - - assertThat(emptyStateContainerView.paddingLeft).isEqualTo(1) - assertThat(emptyStateContainerView.paddingTop).isEqualTo(2) - assertThat(emptyStateContainerView.paddingRight).isEqualTo(3) - assertThat(emptyStateContainerView.paddingBottom).isEqualTo(42) - } - - @Test - fun testShowEmptyState_noOnClickHandler() { - mainListView.visibility = View.VISIBLE - - // Note: an `EmptyState.ClickListener` isn't invoked directly by the UI helper; it has to be - // built into the "on-click handler" that's injected to implement the button-press. We won't - // display the button without a click "handler," even if it *does* have a `ClickListener`. - val clickListener = mock<EmptyState.ClickListener>() - - val emptyState = - object : EmptyState { - override fun getTitle() = "Test title" - override fun getSubtitle() = "Test subtitle" - - override fun getButtonClickListener() = clickListener - } - emptyStateUiHelper.showEmptyState(emptyState, null) - - assertThat(mainListView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateButtonView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) - - assertThat(emptyStateTitleView.text).isEqualTo("Test title") - assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") - - verify(clickListener, never()).onClick(any()) - } - - @Test - fun testShowEmptyState_withOnClickHandlerAndClickListener() { - mainListView.visibility = View.VISIBLE - - val clickListener = mock<EmptyState.ClickListener>() - val onClickHandler = mock<View.OnClickListener>() - - val emptyState = - object : EmptyState { - override fun getTitle() = "Test title" - override fun getSubtitle() = "Test subtitle" - - override fun getButtonClickListener() = clickListener - } - emptyStateUiHelper.showEmptyState(emptyState, onClickHandler) - - assertThat(mainListView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateRootView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateTitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateSubtitleView.visibility).isEqualTo(View.VISIBLE) - assertThat(emptyStateButtonView.visibility).isEqualTo(View.VISIBLE) // Now shown. - assertThat(emptyStateProgressView.visibility).isEqualTo(View.GONE) - assertThat(emptyStateDefaultTextView.visibility).isEqualTo(View.GONE) - - assertThat(emptyStateTitleView.text).isEqualTo("Test title") - assertThat(emptyStateSubtitleView.text).isEqualTo("Test subtitle") - - emptyStateButtonView.performClick() - - verify(onClickHandler).onClick(emptyStateButtonView) - // The test didn't explicitly configure its `OnClickListener` to relay the click event on - // to the `EmptyState.ClickListener`, so it still won't have fired here. - verify(clickListener, never()).onClick(any()) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt deleted file mode 100644 index 59494bed..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ChooserRequestFilteredComponentsTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations - -class ChooserRequestFilteredComponentsTest { - - @Mock lateinit var mockChooserRequestParameters: ChooserRequestParameters - - private lateinit var chooserRequestFilteredComponents: ChooserRequestFilteredComponents - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - chooserRequestFilteredComponents = - ChooserRequestFilteredComponents(mockChooserRequestParameters) - } - - @Test - fun isComponentFiltered_returnsRequestParametersFilteredState() { - // Arrange - whenever(mockChooserRequestParameters.filteredComponentNames) - .thenReturn( - ImmutableList.of(ComponentName("FilteredPackage", "FilteredClass")), - ) - val testComponent = ComponentName("TestPackage", "TestClass") - val filteredComponent = ComponentName("FilteredPackage", "FilteredClass") - - // Act - val result = chooserRequestFilteredComponents.isComponentFiltered(testComponent) - val filteredResult = chooserRequestFilteredComponents.isComponentFiltered(filteredComponent) - - // Assert - assertThat(result).isFalse() - assertThat(filteredResult).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt deleted file mode 100644 index ce40567e..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/FakeResolverComparator.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.ResolveInfo -import android.content.res.Configuration -import android.content.res.Resources -import android.os.Message -import android.os.UserHandle -import com.android.intentresolver.ResolvedComponentInfo -import com.android.intentresolver.chooser.TargetInfo -import com.android.intentresolver.model.AbstractResolverComparator -import com.android.intentresolver.whenever -import java.util.Locale -import org.mockito.Mockito - -class FakeResolverComparator( - context: Context = - Mockito.mock(Context::class.java).also { - val mockResources = Mockito.mock(Resources::class.java) - whenever(it.resources).thenReturn(mockResources) - whenever(mockResources.configuration) - .thenReturn(Configuration().apply { setLocale(Locale.US) }) - }, - targetIntent: Intent = Intent("TestAction"), - resolvedActivityUserSpaceList: List<UserHandle> = emptyList(), - promoteToFirst: ComponentName? = null, -) : - AbstractResolverComparator( - context, - targetIntent, - resolvedActivityUserSpaceList, - promoteToFirst, - ) { - var lastUpdateModel: TargetInfo? = null - private set - var lastUpdateChooserCounts: Triple<String, UserHandle, String>? = null - private set - var destroyCalled = false - private set - - override fun compare(lhs: ResolveInfo?, rhs: ResolveInfo?): Int = - lhs!!.activityInfo.packageName.compareTo(rhs!!.activityInfo.packageName) - - override fun doCompute(targets: MutableList<ResolvedComponentInfo>?) {} - - override fun getScore(targetInfo: TargetInfo?): Float = 1.23f - - override fun handleResultMessage(message: Message?) {} - - override fun updateModel(targetInfo: TargetInfo?) { - lastUpdateModel = targetInfo - } - - override fun updateChooserCounts( - packageName: String, - user: UserHandle, - action: String, - ) { - lastUpdateChooserCounts = Triple(packageName, user, action) - } - - override fun destroy() { - destroyCalled = true - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt deleted file mode 100644 index 396505e6..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/FilterableComponentsTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.whenever -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations - -class FilterableComponentsTest { - - @Mock lateinit var mockChooserRequestParameters: ChooserRequestParameters - - private val unfilteredComponent = ComponentName("TestPackage", "TestClass") - private val filteredComponent = ComponentName("FilteredPackage", "FilteredClass") - private val noComponentFiltering = NoComponentFiltering() - - private lateinit var chooserRequestFilteredComponents: ChooserRequestFilteredComponents - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - chooserRequestFilteredComponents = - ChooserRequestFilteredComponents(mockChooserRequestParameters) - } - - @Test - fun isComponentFiltered_noComponentFiltering_neverFilters() { - // Arrange - - // Act - val unfilteredResult = noComponentFiltering.isComponentFiltered(unfilteredComponent) - val filteredResult = noComponentFiltering.isComponentFiltered(filteredComponent) - - // Assert - assertThat(unfilteredResult).isFalse() - assertThat(filteredResult).isFalse() - } - - @Test - fun isComponentFiltered_chooserRequestFilteredComponents_filtersAccordingToChooserRequest() { - // Arrange - whenever(mockChooserRequestParameters.filteredComponentNames) - .thenReturn( - ImmutableList.of(filteredComponent), - ) - - // Act - val unfilteredResult = - chooserRequestFilteredComponents.isComponentFiltered(unfilteredComponent) - val filteredResult = chooserRequestFilteredComponents.isComponentFiltered(filteredComponent) - - // Assert - assertThat(unfilteredResult).isFalse() - assertThat(filteredResult).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt deleted file mode 100644 index 09f6d373..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/IntentResolverTest.kt +++ /dev/null @@ -1,499 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ActivityInfo -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import android.net.Uri -import android.os.UserHandle -import com.android.intentresolver.any -import com.android.intentresolver.eq -import com.android.intentresolver.kotlinArgumentCaptor -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import java.lang.IndexOutOfBoundsException -import org.junit.Assert.assertThrows -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.verify -import org.mockito.MockitoAnnotations - -class IntentResolverTest { - - @Mock lateinit var mockPackageManager: PackageManager - - private lateinit var intentResolver: IntentResolver - - private val fakePinnableComponents = - object : PinnableComponents { - override fun isComponentPinned(name: ComponentName): Boolean { - return name.packageName == "PinnedPackage" - } - } - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - intentResolver = - IntentResolverImpl(mockPackageManager, ResolveListDeduperImpl(fakePinnableComponents)) - } - - @Test - fun getResolversForIntentAsUser_noIntents_returnsEmptyList() { - // Arrange - val testIntents = emptyList<Intent>() - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun getResolversForIntentAsUser_noResolveInfo_returnsEmptyList() { - // Arrange - val testIntents = listOf(Intent("TestAction")) - val testResolveInfos = emptyList<ResolveInfo>() - whenever(mockPackageManager.queryIntentActivitiesAsUser(any(), anyInt(), any<UserHandle>())) - .thenReturn(testResolveInfos) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun getResolversForIntentAsUser_returnsAllResolveComponentInfo() { - // Arrange - val testIntent1 = Intent("TestAction1") - val testIntent2 = Intent("TestAction2") - val testIntents = listOf(testIntent1, testIntent2) - val testResolveInfos1 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage1" - activityInfo.name = "TestClass1" - }, - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage2" - activityInfo.name = "TestClass2" - }, - ) - val testResolveInfos2 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage3" - activityInfo.name = "TestClass3" - }, - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage4" - activityInfo.name = "TestClass4" - }, - ) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent1), - anyInt(), - any<UserHandle>(), - ) - ) - .thenReturn(testResolveInfos1) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent2), - anyInt(), - any<UserHandle>(), - ) - ) - .thenReturn(testResolveInfos2) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - result.forEachIndexed { index, it -> - val postfix = index + 1 - assertThat(it.name.packageName).isEqualTo("TestPackage$postfix") - assertThat(it.name.className).isEqualTo("TestClass$postfix") - assertThrows(IndexOutOfBoundsException::class.java) { it.getIntentAt(1) } - } - assertThat(result.map { it.getIntentAt(0) }) - .containsExactly( - testIntent1, - testIntent1, - testIntent2, - testIntent2, - ) - } - - @Test - fun getResolversForIntentAsUser_resolveInfoWithoutUserHandle_isSkipped() { - // Arrange - val testIntent = Intent("TestAction") - val testIntents = listOf(testIntent) - val testResolveInfos = - listOf( - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage" - activityInfo.name = "TestClass" - }, - ) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - any(), - anyInt(), - any<UserHandle>(), - ) - ) - .thenReturn(testResolveInfos) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun getResolversForIntentAsUser_duplicateComponents_areCombined() { - // Arrange - val testIntent1 = Intent("TestAction1") - val testIntent2 = Intent("TestAction2") - val testIntents = listOf(testIntent1, testIntent2) - val testResolveInfos1 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "DuplicatePackage" - activityInfo.name = "DuplicateClass" - }, - ) - val testResolveInfos2 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "DuplicatePackage" - activityInfo.name = "DuplicateClass" - }, - ) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent1), - anyInt(), - any<UserHandle>(), - ) - ) - .thenReturn(testResolveInfos1) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent2), - anyInt(), - any<UserHandle>(), - ) - ) - .thenReturn(testResolveInfos2) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result).hasSize(1) - with(result.first()) { - assertThat(name.packageName).isEqualTo("DuplicatePackage") - assertThat(name.className).isEqualTo("DuplicateClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent1) - assertThat(getIntentAt(1)).isEqualTo(testIntent2) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(2) } - } - } - - @Test - fun getResolversForIntentAsUser_pinnedComponentsArePinned() { - // Arrange - val testIntent1 = Intent("TestAction1") - val testIntent2 = Intent("TestAction2") - val testIntents = listOf(testIntent1, testIntent2) - val testResolveInfos1 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "UnpinnedPackage" - activityInfo.name = "UnpinnedClass" - }, - ) - val testResolveInfos2 = - listOf( - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "PinnedPackage" - activityInfo.name = "PinnedClass" - }, - ) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent1), - anyInt(), - any<UserHandle>(), - ) - ) - .thenReturn(testResolveInfos1) - whenever( - mockPackageManager.queryIntentActivitiesAsUser( - eq(testIntent2), - anyInt(), - any<UserHandle>(), - ) - ) - .thenReturn(testResolveInfos2) - - // Act - val result = - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - assertThat(result.map { it.isPinned }).containsExactly(false, true) - } - - @Test - fun getResolversForIntentAsUser_whenNoExtraBehavior_usesBaseFlags() { - // Arrange - val baseFlags = - PackageManager.MATCH_DIRECT_BOOT_AWARE or - PackageManager.MATCH_DIRECT_BOOT_UNAWARE or - PackageManager.MATCH_CLONE_PROFILE - val testIntent = Intent() - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor<Int>() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any<UserHandle>(), - ) - assertThat(flags.value).isEqualTo(baseFlags) - } - - @Test - fun getResolversForIntentAsUser_whenShouldGetResolvedFilter_usesGetResolvedFilterFlag() { - // Arrange - val testIntent = Intent() - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = true, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor<Int>() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any<UserHandle>(), - ) - assertThat(flags.value and PackageManager.GET_RESOLVED_FILTER) - .isEqualTo(PackageManager.GET_RESOLVED_FILTER) - } - - @Test - fun getResolversForIntentAsUser_whenShouldGetActivityMetadata_usesGetMetaDataFlag() { - // Arrange - val testIntent = Intent() - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = true, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor<Int>() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any<UserHandle>(), - ) - assertThat(flags.value and PackageManager.GET_META_DATA) - .isEqualTo(PackageManager.GET_META_DATA) - } - - @Test - fun getResolversForIntentAsUser_whenShouldGetOnlyDefaultActivities_usesMatchDefaultOnlyFlag() { - // Arrange - val testIntent = Intent() - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = true, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor<Int>() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any<UserHandle>(), - ) - assertThat(flags.value and PackageManager.MATCH_DEFAULT_ONLY) - .isEqualTo(PackageManager.MATCH_DEFAULT_ONLY) - } - - @Test - fun getResolversForIntentAsUser_whenWebIntent_usesMatchInstantFlag() { - // Arrange - val testIntent = Intent(Intent.ACTION_VIEW, Uri.fromParts(IntentFilter.SCHEME_HTTP, "", "")) - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor<Int>() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any<UserHandle>(), - ) - assertThat(flags.value and PackageManager.MATCH_INSTANT) - .isEqualTo(PackageManager.MATCH_INSTANT) - } - - @Test - fun getResolversForIntentAsUser_whenActivityMatchExternalFlag_usesMatchInstantFlag() { - // Arrange - val testIntent = Intent().addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) - val testIntents = listOf(testIntent) - - // Act - intentResolver.getResolversForIntentAsUser( - shouldGetResolvedFilter = false, - shouldGetActivityMetadata = false, - shouldGetOnlyDefaultActivities = false, - intents = testIntents, - userHandle = UserHandle(456), - ) - - // Assert - val flags = kotlinArgumentCaptor<Int>() - verify(mockPackageManager) - .queryIntentActivitiesAsUser( - any(), - flags.capture(), - any<UserHandle>(), - ) - assertThat(flags.value and PackageManager.MATCH_INSTANT) - .isEqualTo(PackageManager.MATCH_INSTANT) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt deleted file mode 100644 index ce5e52b1..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/LastChosenManagerTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.ContentResolver -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.IPackageManager -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import com.android.intentresolver.any -import com.android.intentresolver.eq -import com.android.intentresolver.nullable -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.Mockito.isNull -import org.mockito.Mockito.verify -import org.mockito.MockitoAnnotations - -@OptIn(ExperimentalCoroutinesApi::class) -class LastChosenManagerTest { - - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) - private val testTargetIntent = Intent("TestAction") - - @Mock lateinit var mockContentResolver: ContentResolver - @Mock lateinit var mockIPackageManager: IPackageManager - - private lateinit var lastChosenManager: LastChosenManager - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - lastChosenManager = - PackageManagerLastChosenManager(mockContentResolver, testDispatcher, testTargetIntent) { - mockIPackageManager - } - } - - @Test - fun getLastChosen_returnsLastChosenActivity() = - testScope.runTest { - // Arrange - val testResolveInfo = ResolveInfo() - whenever(mockIPackageManager.getLastChosenActivity(any(), nullable(), any())) - .thenReturn(testResolveInfo) - - // Act - val lastChosen = lastChosenManager.getLastChosen() - runCurrent() - - // Assert - verify(mockIPackageManager) - .getLastChosenActivity( - eq(testTargetIntent), - isNull(), - eq(PackageManager.MATCH_DEFAULT_ONLY), - ) - assertThat(lastChosen).isSameInstanceAs(testResolveInfo) - } - - @Test - fun setLastChosen_setsLastChosenActivity() = - testScope.runTest { - // Arrange - val testComponent = ComponentName("TestPackage", "TestClass") - val testIntent = Intent().apply { component = testComponent } - val testIntentFilter = IntentFilter() - val testMatch = 456 - - // Act - lastChosenManager.setLastChosen(testIntent, testIntentFilter, testMatch) - runCurrent() - - // Assert - verify(mockIPackageManager) - .setLastChosenActivity( - eq(testIntent), - isNull(), - eq(PackageManager.MATCH_DEFAULT_ONLY), - eq(testIntentFilter), - eq(testMatch), - eq(testComponent), - ) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt deleted file mode 100644 index 112342ad..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/PinnableComponentsTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.SharedPreferences -import com.android.intentresolver.any -import com.android.intentresolver.eq -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations - -class PinnableComponentsTest { - - @Mock lateinit var mockSharedPreferences: SharedPreferences - - private val unpinnedComponent = ComponentName("TestPackage", "TestClass") - private val pinnedComponent = ComponentName("PinnedPackage", "PinnedClass") - private val noComponentPinning = NoComponentPinning() - - private lateinit var sharedPreferencesPinnedComponents: PinnableComponents - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - sharedPreferencesPinnedComponents = SharedPreferencesPinnedComponents(mockSharedPreferences) - } - - @Test - fun isComponentPinned_noComponentPinning_neverPins() { - // Arrange - - // Act - val unpinnedResult = noComponentPinning.isComponentPinned(unpinnedComponent) - val pinnedResult = noComponentPinning.isComponentPinned(pinnedComponent) - - // Assert - assertThat(unpinnedResult).isFalse() - assertThat(pinnedResult).isFalse() - } - - @Test - fun isComponentFiltered_chooserRequestFilteredComponents_filtersAccordingToChooserRequest() { - // Arrange - whenever(mockSharedPreferences.getBoolean(eq(pinnedComponent.flattenToString()), any())) - .thenReturn(true) - - // Act - val unpinnedResult = sharedPreferencesPinnedComponents.isComponentPinned(unpinnedComponent) - val pinnedResult = sharedPreferencesPinnedComponents.isComponentPinned(pinnedComponent) - - // Assert - assertThat(unpinnedResult).isFalse() - assertThat(pinnedResult).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt deleted file mode 100644 index 26f0199e..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolveListDeduperTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.pm.ResolveInfo -import android.os.UserHandle -import com.android.intentresolver.ResolvedComponentInfo -import com.google.common.truth.Truth.assertThat -import java.lang.IndexOutOfBoundsException -import org.junit.Assert.assertThrows -import org.junit.Before -import org.junit.Test - -class ResolveListDeduperTest { - - private lateinit var resolveListDeduper: ResolveListDeduper - - @Before - fun setup() { - resolveListDeduper = ResolveListDeduperImpl(NoComponentPinning()) - } - - @Test - fun addResolveListDedupe_addsDifferentComponents() { - // Arrange - val testIntent = Intent() - val testResolveInfo1 = - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage1" - activityInfo.name = "TestClass1" - } - val testResolveInfo2 = - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage2" - activityInfo.name = "TestClass2" - } - val testResolvedComponentInfo1 = - ResolvedComponentInfo( - ComponentName("TestPackage1", "TestClass1"), - testIntent, - testResolveInfo1, - ) - .apply { isPinned = false } - val listUnderTest = mutableListOf(testResolvedComponentInfo1) - val listToAdd = listOf(testResolveInfo2) - - // Act - resolveListDeduper.addToResolveListWithDedupe( - into = listUnderTest, - intent = testIntent, - from = listToAdd, - ) - - // Assert - listUnderTest.forEachIndexed { index, it -> - val postfix = index + 1 - assertThat(it.name.packageName).isEqualTo("TestPackage$postfix") - assertThat(it.name.className).isEqualTo("TestClass$postfix") - assertThat(it.getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { it.getIntentAt(1) } - } - } - - @Test - fun addResolveListDedupe_combinesDuplicateComponents() { - // Arrange - val testIntent = Intent() - val testResolveInfo1 = - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "DuplicatePackage" - activityInfo.name = "DuplicateClass" - } - val testResolveInfo2 = - ResolveInfo().apply { - userHandle = UserHandle(456) - activityInfo = ActivityInfo() - activityInfo.packageName = "DuplicatePackage" - activityInfo.name = "DuplicateClass" - } - val testResolvedComponentInfo1 = - ResolvedComponentInfo( - ComponentName("DuplicatePackage", "DuplicateClass"), - testIntent, - testResolveInfo1, - ) - .apply { isPinned = false } - val listUnderTest = mutableListOf(testResolvedComponentInfo1) - val listToAdd = listOf(testResolveInfo2) - - // Act - resolveListDeduper.addToResolveListWithDedupe( - into = listUnderTest, - intent = testIntent, - from = listToAdd, - ) - - // Assert - assertThat(listUnderTest).containsExactly(testResolvedComponentInfo1) - assertThat(testResolvedComponentInfo1.getResolveInfoAt(0)).isEqualTo(testResolveInfo1) - assertThat(testResolvedComponentInfo1.getResolveInfoAt(1)).isEqualTo(testResolveInfo2) - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt deleted file mode 100644 index 9786b801..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentFilteringTest.kt +++ /dev/null @@ -1,309 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import com.android.intentresolver.ResolvedComponentInfo -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertThrows -import org.junit.Before -import org.junit.Test - -class ResolvedComponentFilteringTest { - - private lateinit var resolvedComponentFiltering: ResolvedComponentFiltering - - private val fakeFilterableComponents = - object : FilterableComponents { - override fun isComponentFiltered(name: ComponentName): Boolean { - return name.packageName == "FilteredPackage" - } - } - - private val fakePermissionChecker = - object : PermissionChecker { - override suspend fun checkComponentPermission( - permission: String, - uid: Int, - owningUid: Int, - exported: Boolean - ): Int { - return if (permission == "MissingPermission") { - PackageManager.PERMISSION_DENIED - } else { - PackageManager.PERMISSION_GRANTED - } - } - } - - @Before - fun setup() { - resolvedComponentFiltering = - ResolvedComponentFilteringImpl( - launchedFromUid = 123, - filterableComponents = fakeFilterableComponents, - permissionChecker = fakePermissionChecker, - ) - } - - @Test - fun filterIneligibleActivities_returnsListWithoutFilteredComponents() = runTest { - // Arrange - val testIntent = Intent("TestAction") - val testResolveInfo = - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage" - activityInfo.name = "TestClass" - activityInfo.permission = "TestPermission" - activityInfo.applicationInfo = ApplicationInfo() - activityInfo.applicationInfo.uid = 456 - activityInfo.exported = false - } - val filteredResolveInfo = - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "FilteredPackage" - activityInfo.name = "FilteredClass" - activityInfo.permission = "TestPermission" - activityInfo.applicationInfo = ApplicationInfo() - activityInfo.applicationInfo.uid = 456 - activityInfo.exported = false - } - val missingPermissionResolveInfo = - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "NoPermissionPackage" - activityInfo.name = "NoPermissionClass" - activityInfo.permission = "MissingPermission" - activityInfo.applicationInfo = ApplicationInfo() - activityInfo.applicationInfo.uid = 456 - activityInfo.exported = false - } - val testInput = - listOf( - ResolvedComponentInfo( - ComponentName("TestPackage", "TestClass"), - testIntent, - testResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("FilteredPackage", "FilteredClass"), - testIntent, - filteredResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("NoPermissionPackage", "NoPermissionClass"), - testIntent, - missingPermissionResolveInfo, - ) - ) - - // Act - val result = resolvedComponentFiltering.filterIneligibleActivities(testInput) - - // Assert - assertThat(result).hasSize(1) - with(result.first()) { - assertThat(name.packageName).isEqualTo("TestPackage") - assertThat(name.className).isEqualTo("TestClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - } - - @Test - fun filterLowPriority_filtersAfterFirstDifferentPriority() { - // Arrange - val testIntent = Intent("TestAction") - val testResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val equalResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val diffResolveInfo = - ResolveInfo().apply { - priority = 2 - isDefault = true - } - val testInput = - listOf( - ResolvedComponentInfo( - ComponentName("TestPackage", "TestClass"), - testIntent, - testResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("EqualPackage", "EqualClass"), - testIntent, - equalResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("DiffPackage", "DiffClass"), - testIntent, - diffResolveInfo, - ), - ) - - // Act - val result = resolvedComponentFiltering.filterLowPriority(testInput) - - // Assert - assertThat(result).hasSize(2) - with(result.first()) { - assertThat(name.packageName).isEqualTo("TestPackage") - assertThat(name.className).isEqualTo("TestClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - with(result[1]) { - assertThat(name.packageName).isEqualTo("EqualPackage") - assertThat(name.className).isEqualTo("EqualClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(equalResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - } - - @Test - fun filterLowPriority_filtersAfterFirstDifferentDefault() { - // Arrange - val testIntent = Intent("TestAction") - val testResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val equalResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val diffResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = false - } - val testInput = - listOf( - ResolvedComponentInfo( - ComponentName("TestPackage", "TestClass"), - testIntent, - testResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("EqualPackage", "EqualClass"), - testIntent, - equalResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("DiffPackage", "DiffClass"), - testIntent, - diffResolveInfo, - ), - ) - - // Act - val result = resolvedComponentFiltering.filterLowPriority(testInput) - - // Assert - assertThat(result).hasSize(2) - with(result.first()) { - assertThat(name.packageName).isEqualTo("TestPackage") - assertThat(name.className).isEqualTo("TestClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - with(result[1]) { - assertThat(name.packageName).isEqualTo("EqualPackage") - assertThat(name.className).isEqualTo("EqualClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(equalResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - } - - @Test - fun filterLowPriority_whenNoDifference_returnsOriginal() { - // Arrange - val testIntent = Intent("TestAction") - val testResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val equalResolveInfo = - ResolveInfo().apply { - priority = 1 - isDefault = true - } - val testInput = - listOf( - ResolvedComponentInfo( - ComponentName("TestPackage", "TestClass"), - testIntent, - testResolveInfo, - ), - ResolvedComponentInfo( - ComponentName("EqualPackage", "EqualClass"), - testIntent, - equalResolveInfo, - ), - ) - - // Act - val result = resolvedComponentFiltering.filterLowPriority(testInput) - - // Assert - assertThat(result).hasSize(2) - with(result.first()) { - assertThat(name.packageName).isEqualTo("TestPackage") - assertThat(name.className).isEqualTo("TestClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(testResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - with(result[1]) { - assertThat(name.packageName).isEqualTo("EqualPackage") - assertThat(name.className).isEqualTo("EqualClass") - assertThat(getIntentAt(0)).isEqualTo(testIntent) - assertThrows(IndexOutOfBoundsException::class.java) { getIntentAt(1) } - assertThat(getResolveInfoAt(0)).isEqualTo(equalResolveInfo) - assertThrows(IndexOutOfBoundsException::class.java) { getResolveInfoAt(1) } - } - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt deleted file mode 100644 index 39b328ee..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/ResolvedComponentSortingTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.pm.ApplicationInfo -import android.content.pm.ResolveInfo -import android.os.UserHandle -import com.android.intentresolver.ResolvedComponentInfo -import com.android.intentresolver.chooser.DisplayResolveInfo -import com.android.intentresolver.chooser.TargetInfo -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.Mockito - -@OptIn(ExperimentalCoroutinesApi::class) -class ResolvedComponentSortingTest { - - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private val fakeResolverComparator = FakeResolverComparator() - - private val resolvedComponentSorting = - ResolvedComponentSortingImpl(testDispatcher, fakeResolverComparator) - - @Test - fun sorted_onNullList_returnsNull() = - testScope.runTest { - // Arrange - val testInput: List<ResolvedComponentInfo>? = null - - // Act - val result = resolvedComponentSorting.sorted(testInput) - runCurrent() - - // Assert - assertThat(result).isNull() - } - - @Test - fun sorted_onEmptyList_returnsEmptyList() = - testScope.runTest { - // Arrange - val testInput = emptyList<ResolvedComponentInfo>() - - // Act - val result = resolvedComponentSorting.sorted(testInput) - runCurrent() - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun sorted_returnsListSortedByGivenComparator() = - testScope.runTest { - // Arrange - val testIntent = Intent("TestAction") - val testInput = - listOf( - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage3" - activityInfo.name = "TestClass3" - }, - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage1" - activityInfo.name = "TestClass1" - }, - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.packageName = "TestPackage2" - activityInfo.name = "TestClass2" - }, - ) - .map { - it.targetUserId = UserHandle.USER_CURRENT - ResolvedComponentInfo( - ComponentName(it.activityInfo.packageName, it.activityInfo.name), - testIntent, - it, - ) - } - - // Act - val result = async { resolvedComponentSorting.sorted(testInput) } - runCurrent() - - // Assert - assertThat(result.await()?.map { it.name.packageName }) - .containsExactly("TestPackage1", "TestPackage2", "TestPackage3") - .inOrder() - } - - @Test - fun getScore_displayResolveInfo_returnsTheScoreAccordingToTheResolverComparator() { - // Arrange - val testTarget = - DisplayResolveInfo.newDisplayResolveInfo( - Intent(), - ResolveInfo().apply { - activityInfo = ActivityInfo() - activityInfo.name = "TestClass" - activityInfo.applicationInfo = ApplicationInfo() - activityInfo.applicationInfo.packageName = "TestPackage" - }, - Intent(), - ) - - // Act - val result = resolvedComponentSorting.getScore(testTarget) - - // Assert - assertThat(result).isEqualTo(1.23f) - } - - @Test - fun getScore_targetInfo_returnsTheScoreAccordingToTheResolverComparator() { - // Arrange - val mockTargetInfo = Mockito.mock(TargetInfo::class.java) - - // Act - val result = resolvedComponentSorting.getScore(mockTargetInfo) - - // Assert - assertThat(result).isEqualTo(1.23f) - } - - @Test - fun updateModel_updatesResolverComparatorModel() = - testScope.runTest { - // Arrange - val mockTargetInfo = Mockito.mock(TargetInfo::class.java) - assertThat(fakeResolverComparator.lastUpdateModel).isNull() - - // Act - resolvedComponentSorting.updateModel(mockTargetInfo) - runCurrent() - - // Assert - assertThat(fakeResolverComparator.lastUpdateModel).isSameInstanceAs(mockTargetInfo) - } - - @Test - fun updateChooserCounts_updatesResolverComparaterChooserCounts() = - testScope.runTest { - // Arrange - val testPackageName = "TestPackage" - val testUser = UserHandle(456) - val testAction = "TestAction" - assertThat(fakeResolverComparator.lastUpdateChooserCounts).isNull() - - // Act - resolvedComponentSorting.updateChooserCounts(testPackageName, testUser, testAction) - runCurrent() - - // Assert - assertThat(fakeResolverComparator.lastUpdateChooserCounts) - .isEqualTo(Triple(testPackageName, testUser, testAction)) - } - - @Test - fun destroy_destroysResolverComparator() { - // Arrange - assertThat(fakeResolverComparator.destroyCalled).isFalse() - - // Act - resolvedComponentSorting.destroy() - - // Assert - assertThat(fakeResolverComparator.destroyCalled).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt b/tests/unit/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt deleted file mode 100644 index 9d6394fa..00000000 --- a/tests/unit/src/com/android/intentresolver/v2/listcontroller/SharedPreferencesPinnedComponentsTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.v2.listcontroller - -import android.content.ComponentName -import android.content.SharedPreferences -import com.android.intentresolver.any -import com.android.intentresolver.eq -import com.android.intentresolver.whenever -import com.google.common.truth.Truth -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.Mockito -import org.mockito.MockitoAnnotations - -class SharedPreferencesPinnedComponentsTest { - - @Mock lateinit var mockSharedPreferences: SharedPreferences - - private lateinit var sharedPreferencesPinnedComponents: SharedPreferencesPinnedComponents - - @Before - fun setup() { - MockitoAnnotations.initMocks(this) - - sharedPreferencesPinnedComponents = SharedPreferencesPinnedComponents(mockSharedPreferences) - } - - @Test - fun isComponentPinned_returnsSavedPinnedState() { - // Arrange - val testComponent = ComponentName("TestPackage", "TestClass") - val pinnedComponent = ComponentName("PinnedPackage", "PinnedClass") - whenever(mockSharedPreferences.getBoolean(eq(pinnedComponent.flattenToString()), any())) - .thenReturn(true) - - // Act - val result = sharedPreferencesPinnedComponents.isComponentPinned(testComponent) - val pinnedResult = sharedPreferencesPinnedComponents.isComponentPinned(pinnedComponent) - - // Assert - Mockito.verify(mockSharedPreferences).getBoolean(eq(testComponent.flattenToString()), any()) - Mockito.verify(mockSharedPreferences) - .getBoolean(eq(pinnedComponent.flattenToString()), any()) - Truth.assertThat(result).isFalse() - Truth.assertThat(pinnedResult).isTrue() - } -} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt b/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt index dbaa7c4e..93a5ec0c 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt +++ b/tests/unit/src/com/android/intentresolver/validation/ValidationTest.kt @@ -1,6 +1,22 @@ -package com.android.intentresolver.v2.validation - -import com.android.intentresolver.v2.validation.types.value +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.validation + +import com.android.intentresolver.validation.types.value import com.google.common.truth.Truth.assertThat import org.junit.Assert.fail import org.junit.Test @@ -36,8 +52,7 @@ class ValidationTest { assertThat(result).isInstanceOf(Invalid::class.java) result as Invalid<String> - assertThat(result.errors).containsExactly( - NoValue("key", Importance.CRITICAL, Int::class)) + assertThat(result.errors).containsExactly(NoValue("key", Importance.CRITICAL, Int::class)) } /** Test optional values are ignored when absent. */ @@ -85,8 +100,7 @@ class ValidationTest { result as Valid<String> assertThat(result.value).isEqualTo("result value") - assertThat(result.warnings) - .containsExactly(IgnoredValue("key", "no longer supported")) + assertThat(result.warnings).containsExactly(IgnoredValue("key", "no longer supported")) } /** Test reporting of ignored values. */ @@ -107,10 +121,7 @@ class ValidationTest { /** Test handling of exceptions in the validation function. */ @Test fun thrown_exception() { - val result: ValidationResult<String> = - validateFrom({ null }) { - error("something") - } + val result: ValidationResult<String> = validateFrom({ null }) { error("something") } assertThat(result).isInstanceOf(Invalid::class.java) result as Invalid<String> @@ -118,5 +129,4 @@ class ValidationTest { val errorType = result.errors.map { it::class }.first() assertThat(errorType).isEqualTo(UncaughtException::class) } - } diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt index 03429f4c..f8622ce0 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt +++ b/tests/unit/src/com/android/intentresolver/validation/types/IntentOrUriTest.kt @@ -1,16 +1,32 @@ -package com.android.intentresolver.v2.validation.types +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.validation.types import android.content.Intent import android.content.Intent.URI_INTENT_SCHEME import android.net.Uri import androidx.core.net.toUri import androidx.test.ext.truth.content.IntentSubject.assertThat -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValueIsWrongType +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValueIsWrongType import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -52,8 +68,7 @@ class IntentOrUriTest { assertThat(result).isInstanceOf(Invalid::class.java) result as Invalid<Intent> - assertThat(result.errors) - .containsExactly(NoValue("key", CRITICAL, Intent::class)) + assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Intent::class)) } /** Check validation passes when value is null and importance is [WARNING] (optional). */ @@ -92,9 +107,7 @@ class IntentOrUriTest { ) } - /** - * Test for warnings when the value is neither Intent nor Uri, with importance WARNING. - */ + /** Test for warnings when the value is neither Intent nor Uri, with importance WARNING. */ @Test fun wrongType_optional() { val keyValidator = IntentOrUri("key") @@ -106,13 +119,13 @@ class IntentOrUriTest { result as Invalid<Intent> assertThat(result.errors) - .containsExactly( - ValueIsWrongType( - "key", - importance = WARNING, - actualType = Int::class, - allowedTypes = listOf(Intent::class, Uri::class) - ) + .containsExactly( + ValueIsWrongType( + "key", + importance = WARNING, + actualType = Int::class, + allowedTypes = listOf(Intent::class, Uri::class) ) + ) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt index 637873ea..5284cbec 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt +++ b/tests/unit/src/com/android/intentresolver/validation/types/ParceledArrayTest.kt @@ -1,14 +1,30 @@ -package com.android.intentresolver.v2.validation.types +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.validation.types import android.content.Intent import android.graphics.Point -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValueIsWrongType -import com.android.intentresolver.v2.validation.WrongElementType +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValueIsWrongType +import com.android.intentresolver.validation.WrongElementType import com.google.common.truth.Truth.assertThat import org.junit.Test diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt b/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt index 93d76d46..1b6bace1 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt +++ b/tests/unit/src/com/android/intentresolver/validation/types/SimpleValueTest.kt @@ -1,13 +1,29 @@ -package com.android.intentresolver.v2.validation.types - -import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.Importance.WARNING -import com.android.intentresolver.v2.validation.Invalid -import com.android.intentresolver.v2.validation.NoValue -import com.android.intentresolver.v2.validation.Valid -import com.android.intentresolver.v2.validation.ValueIsWrongType -import org.junit.Test +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.validation.types + +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValueIsWrongType import com.google.common.truth.Truth.assertThat +import org.junit.Test class SimpleValueTest { @@ -19,7 +35,6 @@ class SimpleValueTest { val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).isInstanceOf(Valid::class.java) result as Valid<Double> assertThat(result.value).isEqualTo(Math.PI) @@ -35,14 +50,15 @@ class SimpleValueTest { assertThat(result).isInstanceOf(Invalid::class.java) result as Invalid<Double> - assertThat(result.errors).containsExactly( - ValueIsWrongType( - "key", - importance = CRITICAL, - actualType = String::class, - allowedTypes = listOf(Double::class) + assertThat(result.errors) + .containsExactly( + ValueIsWrongType( + "key", + importance = CRITICAL, + actualType = String::class, + allowedTypes = listOf(Double::class) + ) ) - ) } /** Test the failure result when the value is missing. */ @@ -58,7 +74,6 @@ class SimpleValueTest { assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Double::class)) } - /** Test the failure result when the value is missing. */ @Test fun optional() { |