diff options
author | 2024-06-13 10:50:36 -0700 | |
---|---|---|
committer | 2024-06-13 10:50:36 -0700 | |
commit | 02a259011dec677fc7daaf98971cb9b012b7b737 (patch) | |
tree | 52c3afd5e507b324ce84f6424196ff083cee9202 | |
parent | c6a7e2cd37d5ac38e1898d8c775691c4acb768f8 (diff) | |
parent | 6d6ed17777584d93f36d229b645cb9675a07f137 (diff) |
Merge Android 14 QPR3 to AOSP main
Bug: 346855327
Merged-In: I611c31f6aab968a064b6e35c05ac75024516f3b6
Change-Id: Iab64fb0d7b5d4a2d576e726f739683050fad20f7
247 files changed, 11012 insertions, 2615 deletions
@@ -59,6 +59,15 @@ android_library { "kotlinx-coroutines-android", "//external/kotlinc:kotlin-annotations", "guava", + "PlatformComposeCore", + "PlatformComposeSceneTransitionLayout", + "androidx.compose.runtime_runtime", + "androidx.compose.material3_material3", + "androidx.compose.material_material-icons-extended", + "androidx.activity_activity-compose", + "androidx.compose.animation_animation-graphics", + "androidx.lifecycle_lifecycle-viewmodel-compose", + "androidx.lifecycle_lifecycle-runtime-compose", ], } diff --git a/AndroidManifest-lib.xml b/AndroidManifest-lib.xml index b3a43eb3..bdb94232 100644 --- a/AndroidManifest-lib.xml +++ b/AndroidManifest-lib.xml @@ -32,4 +32,6 @@ <uses-permission android:name="android.permission.QUERY_CLONED_APPS" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.REPORT_USAGE_STATS" /> + <uses-permission android:name="android.permission.LOG_COMPAT_CHANGE" /> + <uses-permission android:name="android.permission.READ_COMPAT_CHANGE_CONFIG" /> </manifest> diff --git a/TEST_MAPPING b/TEST_MAPPING index de28a495..51c7bd43 100644 --- a/TEST_MAPPING +++ b/TEST_MAPPING @@ -2,13 +2,13 @@ "presubmit": [ { "name": "IntentResolver-tests-unit" + }, + { + "name": "IntentResolver-tests-activity" } ], "postsubmit": [ { - "name": "IntentResolver-tests-activity" - }, - { "name": "IntentResolver-tests-integration" } ] diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index d67d7abe..04883baf 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -6,10 +6,13 @@ container: "system" # bug: "Feature_Bug_#" or "<none>" flag { - name: "example_new_sharing_method" + name: "fix_target_list_footer" namespace: "intentresolver" - description: "Enables the example new sharing mechanism." - bug: "<none>" + description: "Update app target grid footer on window insets change" + bug: "324011248" + metadata { + purpose: PURPOSE_BUGFIX + } } flag { @@ -32,3 +35,17 @@ flag { description: "Enables the new modular framework" bug: "302113519" } + +flag { + name: "bespoke_label_view" + namespace: "intentresolver" + description: "Use a custom view to draw target labels" + bug: "302188527" +} + +flag { + name: "enable_private_profile" + namespace: "intentresolver" + description: "Enable private profile support" + bug: "311348033" +} diff --git a/java/res/drawable/checkbox.xml b/java/res/drawable/checkbox.xml new file mode 100644 index 00000000..189d01ff --- /dev/null +++ b/java/res/drawable/checkbox.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + <path + android:pathData="M10,0C4.48,0 0,4.48 0,10C0,15.52 4.48,20 10,20C15.52,20 20,15.52 20,10C20,4.48 15.52,0 10,0ZM10,18C5.59,18 2,14.41 2,10C2,5.59 5.59,2 10,2C14.41,2 18,5.59 18,10C18,14.41 14.41,18 10,18ZM5.4,9.6L8,12.2L14.6,5.6L16,7L8,15L4,11L5.4,9.6Z" + android:fillColor="#ffffff" + android:fillType="evenOdd"/> +</vector> diff --git a/java/res/drawable/ic_play_circle_filled_24px.xml b/java/res/drawable/ic_play_circle_filled_24px.xml new file mode 100644 index 00000000..f67127ca --- /dev/null +++ b/java/res/drawable/ic_play_circle_filled_24px.xml @@ -0,0 +1,3 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="20dp" android:viewportHeight="20" android:viewportWidth="20" android:width="20dp"> + <path android:fillColor="#ffffff" android:fillType="evenOdd" android:pathData="M0,10C0,4.48 4.48,0 10,0C15.52,0 20,4.48 20,10C20,15.52 15.52,20 10,20C4.48,20 0,15.52 0,10ZM14,10L8,5.5V14.5L14,10Z"/> +</vector> diff --git a/java/res/layout/chooser_action_view.xml b/java/res/layout/chooser_action_view.xml index e17dce0e..d045a7e3 100644 --- a/java/res/layout/chooser_action_view.xml +++ b/java/res/layout/chooser_action_view.xml @@ -27,6 +27,5 @@ android:ellipsize="end" android:gravity="center" android:maxLines="1" - android:maxWidth="@dimen/chooser_action_max_width" android:textColor="?androidprv:attr/materialColorOnSurface" android:textSize="12sp" /> diff --git a/java/res/layout/chooser_grid_item.xml b/java/res/layout/chooser_grid_item.xml new file mode 100644 index 00000000..18abc7bc --- /dev/null +++ b/java/res/layout/chooser_grid_item.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 2006, The Android Open Source Project +** +** Licensed under the Apache License, Version 2.0 (the "License"); +** you may not use this file except in compliance with the License. +** You may obtain a copy of the License at +** +** http://www.apache.org/licenses/LICENSE-2.0 +** +** Unless required by applicable law or agreed to in writing, software +** distributed under the License is distributed on an "AS IS" BASIS, +** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +** See the License for the specific language governing permissions and +** limitations under the License. +*/ +--> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:id="@androidprv:id/item" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="100dp" + android:gravity="top|center_horizontal" + android:paddingVertical="@dimen/grid_padding" + android:paddingHorizontal="4dp" + android:focusable="true" + android:background="?android:attr/selectableItemBackgroundBorderless"> + + <ImageView android:id="@android:id/icon" + android:layout_width="@dimen/chooser_icon_size" + android:layout_height="@dimen/chooser_icon_size" + android:layout_marginHorizontal="8dp" + android:scaleType="fitCenter" /> + + <!-- Size manually tuned to match specs --> + <Space android:layout_width="1dp" + android:layout_height="7dp"/> + + <!-- NOTE: for id/text1 and id/text2 below set the width to match parent as a workaround for + b/269395540 i.e. prevent views bounds change during a transition animation. It does not + affect pinned views as we change their layout parameters programmatically (but that's even + more narrow possibility and it's not clear if the root cause or the bug would affect it). + --> + <!-- App name or Direct Share target name, DS set to 2 lines --> + <com.android.intentresolver.widget.BadgeTextView + android:id="@android:id/text1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="?androidprv:attr/materialColorOnSurface" + android:textSize="12sp" + android:maxLines="1" + android:ellipsize="end" /> + + <!-- Activity name if set, gone for Direct Share targets --> + <TextView android:id="@android:id/text2" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textSize="12sp" + android:textColor="?androidprv:attr/materialColorOnSurfaceVariant" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:lines="1" + android:gravity="top|center_horizontal" + android:ellipsize="end"/> + +</LinearLayout> + diff --git a/java/res/layout/chooser_headline_row.xml b/java/res/layout/chooser_headline_row.xml index 62781847..97e8552e 100644 --- a/java/res/layout/chooser_headline_row.xml +++ b/java/res/layout/chooser_headline_row.xml @@ -38,6 +38,21 @@ android:textSize="18sp" /> + <TextView + android:id="@+id/metadata" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:visibility="gone" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@id/barrier" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constrainedWidth="true" + app:layout_constraintTop_toBottomOf="@id/headline" + style="@style/TextAppearance.ChooserDefault" + android:fontFamily="@androidprv:string/config_bodyFontFamily" + android:textSize="12sp" + /> + <androidx.constraintlayout.widget.Barrier android:id="@+id/barrier" android:layout_width="wrap_content" diff --git a/java/res/layout/chooser_list_per_profile_wrap.xml b/java/res/layout/chooser_list_per_profile_wrap.xml index 157fa75d..fc0431d7 100644 --- a/java/res/layout/chooser_list_per_profile_wrap.xml +++ b/java/res/layout/chooser_list_per_profile_wrap.xml @@ -35,7 +35,6 @@ android:clipToPadding="false" android:background="?androidprv:attr/materialColorSurfaceContainer" android:scrollbars="none" - android:elevation="1dp" android:nestedScrollingEnabled="true" /> <include layout="@layout/resolver_empty_states" /> diff --git a/java/res/values-af/strings.xml b/java/res/values-af/strings.xml index e0a73836..4ce4e614 100644 --- a/java/res/values-af/strings.xml +++ b/java/res/values-af/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deel tans video met skakel}other{Deel tans # video’s met skakel}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deel tans lêer met teks}other{Deel tans # lêers met teks}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deel tans lêer met skakel}other{Deel tans # lêers met skakel}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deel tans album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Net prent}other{Net prente}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Net video}other{Net video’s}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Net lêer}other{Net lêers}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Opneemtoestemming is nie aan hierdie program verleen nie, maar dit kan oudio deur hierdie USB-toestel opneem."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Persoonlik"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Werk"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privaat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Persoonlike aansig"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Werkaansig"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privaat aansig"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Deur jou IT-admin geblokkeer"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Hierdie inhoud kan nie met werkprogramme gedeel word nie"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Hierdie inhoud kan nie met werkprogramme oopgemaak word nie"</string> diff --git a/java/res/values-am/strings.xml b/java/res/values-am/strings.xml index ba6409fd..e8c5a033 100644 --- a/java/res/values-am/strings.xml +++ b/java/res/values-am/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ቪድዮ ከአገናኝ ጋር በማጋራት ላይ}one{# ቪድዮ ከአገናኝ ጋር በማጋራት ላይ}other{# ቪድዮዎችን ከአገናኝ ጋር በማጋራት ላይ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ፋይልን ከጽሑፍ ጋር በማጋራት ላይ}one{# ፋይልን ከጽሑፍ ጋር በማጋራት ላይ}other{# ፋይሎችን ከጽሑፍ ጋር በማጋራት ላይ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ፋይልን ከአገናኝ ጋር በማጋራት ላይ}one{# ፋይልን ከአገናኝ ጋር በማጋራት ላይ}other{# ፋይሎችን ከአገናኝ ጋር በማጋራት ላይ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"የተጋራ አልበም"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ምስል ብቻ}one{ምስል ብቻ}other{ምስሎች ብቻ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ቪድዮ ብቻ}one{ቪድዮ ብቻ}other{ቪድዮዎች ብቻ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ፋይል ብቻ}one{ፋይል ብቻ}other{ፋይሎች ብቻ}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ይህ መተግበሪያ የመቅረጽ ፈቃድ አልተሰጠውም፣ ነገር ግን በዚህ ዩኤስቢ መሣሪያ በኩል ኦዲዮን መቅረጽ ይችላል።"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"የግል"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ሥራ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"የግል"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"የግል ዕይታ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"የስራ ዕይታ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"የግል ዕይታ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"በእርስዎ የአይቲ አስተዳዳሪ ታግዷል"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ይህ ይዘት በሥራ መተግበሪያዎች መጋራት አይችልም"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ይህ ይዘት በሥራ መተግበሪያዎች መከፈት አይችልም"</string> diff --git a/java/res/values-ar/strings.xml b/java/res/values-ar/strings.xml index da8d4de2..a5979327 100644 --- a/java/res/values-ar/strings.xml +++ b/java/res/values-ar/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{مشاركة فيديو واحد ورابط}zero{مشاركة # فيديو ورابط}two{مشاركة فيديوهَين ورابط}few{مشاركة # فيديوهات ورابط}many{مشاركة # فيديو ورابط}other{مشاركة # فيديو ورابط}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{مشاركة ملف واحد ونص}zero{مشاركة # ملف ونص}two{مشاركة # ملفَّين ونص}few{مشاركة # ملفات ونص}many{مشاركة # ملفًا ونص}other{مشاركة # ملف ونص}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{مشاركة ملف واحد ورابط}zero{مشاركة # ملف ورابط}two{مشاركة ملفَّين ورابط}few{مشاركة # ملفات ورابط}many{مشاركة # ملفًا ورابط}other{مشاركة # ملف ورابط}}"</string> + <string name="sharing_album" msgid="191743129899503345">"مشاركة الألبوم"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{الصورة فقط}zero{الصور فقط}two{الصورتان فقط}few{الصور فقط}many{الصور فقط}other{الصور فقط}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{الفيديو فقط}zero{الفيديوهات فقط}two{الفيديوهان فقط}few{الفيديوهات فقط}many{الفيديوهات فقط}other{الفيديوهات فقط}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{الملف فقط}zero{الملفات فقط}two{الملفان فقط}few{الملفات فقط}many{الملفات فقط}other{الملفات فقط}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"لم يتم منح هذا التطبيق إذن تسجيل، ولكن يمكنه تسجيل الصوت من خلال جهاز USB هذا."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"شخصي"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"للعمل"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"المساحة الخاصّة"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"عرض المحتوى الشخصي"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"عرض محتوى العمل"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"عرض المساحة الخاصّة"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"حظر مشرف تكنولوجيا المعلومات مشاركة المحتوى"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"لا يمكن مشاركة هذا المحتوى مع تطبيقات العمل."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"لا يمكن فتح هذا المحتوى باستخدام تطبيقات العمل."</string> diff --git a/java/res/values-as/strings.xml b/java/res/values-as/strings.xml index 14bd864e..dd677968 100644 --- a/java/res/values-as/strings.xml +++ b/java/res/values-as/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{লিংকৰ সৈতে ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}one{লিংকৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}other{লিংকৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{পাঠৰ সৈতে ফাইল শ্বেয়াৰ কৰি থকা হৈছে}one{পাঠৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}other{পাঠৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{লিংকৰ সৈতে ফাইল শ্বেয়াৰ কৰি থকা হৈছে}one{লিংকৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}other{লিংকৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}}"</string> + <string name="sharing_album" msgid="191743129899503345">"এলবাম শ্বেয়াৰ কৰি থকা হৈছে"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{কেৱল প্ৰতিচ্ছবি}one{কেৱল প্ৰতিচ্ছবিসমূহ}other{কেৱল প্ৰতিচ্ছবিসমূহ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{কেৱল ভিডিঅ’}one{কেৱল ভিডিঅ’সমূহ}other{কেৱল ভিডিঅ’সমূহ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{কেৱল ফাইল}one{কেৱল ফাইলসমূহ}other{কেৱল ফাইলসমূহ}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"এই এপ্টোক ৰেকর্ড কৰাৰ অনুমতি দিয়া হোৱা নাই কিন্তু ই এই ইউএছবি ডিভাইচটোৰ জৰিয়তে অডিঅ\' ৰেকর্ড কৰিব পাৰে।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ব্যক্তিগত"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"কৰ্মস্থান"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ব্যক্তিগত"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ব্যক্তিগত ভিউ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"কৰ্মস্থানৰ ভিউ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ব্যক্তিগত ভিউ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"আপোনাৰ আইটি প্ৰশাসকে অৱৰোধ কৰিছে"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"এই সমল কৰ্মস্থানৰ এপৰ সৈতে শ্বেয়াৰ কৰিব নোৱাৰি"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"এই সমল কৰ্মস্থানৰ এপৰ জৰিয়তে খুলিব নোৱাৰি"</string> diff --git a/java/res/values-az/strings.xml b/java/res/values-az/strings.xml index a31df362..39e396d0 100644 --- a/java/res/values-az/strings.xml +++ b/java/res/values-az/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Link olan video paylaşılır}other{Link olan # video paylaşılır}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Mətn olan fayl paylaşılır}other{Mətn olan # fayl paylaşılır}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Link olan fayl paylaşılır}other{Link olan # fayl paylaşılır}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albom paylaşılır"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Yalnız şəkil}other{Yalnız şəkillər}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Yalnız video}other{Yalnız videolar}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Yalnız fayl}other{Yalnız fayllar}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Tətbiqə qeydə almaq icazəsi verilməsə də, bu USB vasitəsilə səsi qeydə ala bilər."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Şəxsi"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"İş"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Şəxsi"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Şəxsi məzmuna baxış"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"İş məzmununa baxış"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Şəxsi baxış"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT admininiz tərəfindən bloklanıb"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bu kontenti iş tətbiqləri ilə paylaşmaq mümkün deyil"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu kontenti iş tətbiqləri ilə açmaq mümkün deyil"</string> diff --git a/java/res/values-b+sr+Latn/strings.xml b/java/res/values-b+sr+Latn/strings.xml index ea0d87b3..91dd0c13 100644 --- a/java/res/values-b+sr+Latn/strings.xml +++ b/java/res/values-b+sr+Latn/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ još # fajl}one{+ još # fajl}few{+ još # fajla}other{+ još # fajlova}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Deli se tekst"</string> <string name="sharing_link" msgid="2307694372813942916">"Deli se link"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deli se slika}one{Deli se # slika}few{Dele se # slike}other{Deli se # slika}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deljenje slike}one{Deljenje # slike}few{Deljenje # slike}other{Deljenje # slika}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deli se video}one{Deli se # video}few{Dele se # video snimka}other{Deli se # videa}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deli se # fajl}one{Deli se # fajl}few{Dele se # fajla}other{Deli se # fajlova}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deli se slika sa tekstom}one{Deli se # slika sa tekstom}few{Dele se # slike sa tekstom}other{Deli se # slika sa tekstom}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deli se video sa linkom}one{Deli se # video sa linkom}few{Dele se # video snimka sa linkom}other{Deli se # videa sa linkom}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deli se fajl sa tekstom}one{Deli se # fajl sa tekstom}few{Dele se # fajla sa tekstom}other{Deli se # fajlova sa tekstom}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deli se fajl sa linkom}one{Deli se # fajl sa linkom}few{Dele se # fajla sa linkom}other{Deli se # fajlova sa linkom}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deljeni album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}few{Samo slike}other{Samo slike}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo video}one{Samo video snimci}few{Samo video snimci}other{Samo video snimci}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo fajl}one{Samo fajlovi}few{Samo fajlovi}other{Samo fajlovi}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ova aplikacija nema dozvolu za snimanje, ali bi mogla da snima zvuk pomoću ovog USB uređaja."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Lično"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Poslovno"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privatno"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Lični prikaz"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Prikaz za posao"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatni prikaz"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokira IT administrator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ovaj sadržaj ne može da se deli pomoću poslovnih aplikacija"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ovaj sadržaj ne može da se otvara pomoću poslovnih aplikacija"</string> diff --git a/java/res/values-be/strings.xml b/java/res/values-be/strings.xml index aecc1cbd..2f5d8004 100644 --- a/java/res/values-be/strings.xml +++ b/java/res/values-be/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Абагульванне відэа са спасылкай}one{Абагульванне # відэа са спасылкай}few{Абагульванне # відэа са спасылкай}many{Абагульванне # відэа са спасылкай}other{Абагульванне # відэа са спасылкай}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Абагульванне файла з тэкстам}one{Абагульванне # файла з тэкстам}few{Абагульванне # файлаў з тэкстам}many{Абагульванне # файлаў з тэкстам}other{Абагульванне # файла з тэкстам}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Абагульванне файла са спасылкай}one{Абагульванне # файла са спасылкай}few{Абагульванне # файлаў са спасылкай}many{Абагульванне # файлаў са спасылкай}other{Абагульванне # файла са спасылкай}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Ідзе абагульванне альбома"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Толькі відарыс}one{Толькі відарысы}few{Толькі відарысы}many{Толькі відарысы}other{Толькі відарысы}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Толькі відэа}one{Толькі відэа}few{Толькі відэа}many{Толькі відэа}other{Толькі відэа}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Толькі файл}one{Толькі файлы}few{Толькі файлы}many{Толькі файлы}other{Толькі файлы}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"У гэтай праграмы няма дазволу на запіс, аднак яна зможа запісваць аўдыя праз гэту USB-прыладу."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Асабісты"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Працоўны"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Прыватная прастора"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Прагляд асабістага змесціва"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Прагляд працоўнага змесціва"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Прыватная прастора"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Заблакіравана вашым ІТ-адміністратарам"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Не ўдалося абагуліць гэта змесціва з працоўнымі праграмамі"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Не ўдалося адкрыць гэта змесціва з дапамогай працоўных праграм"</string> diff --git a/java/res/values-bg/strings.xml b/java/res/values-bg/strings.xml index 5bc22d73..2736cb13 100644 --- a/java/res/values-bg/strings.xml +++ b/java/res/values-bg/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Споделяне на видеоклипа чрез връзка}other{Споделяне на # видеоклипа чрез връзка}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Споделяне на файла чрез SMS съобщение}other{Споделяне на # файла чрез SMS съобщение}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Споделяне на файла чрез връзка}other{Споделяне на # файла чрез връзка}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Споделяне на албума"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Само изображение}other{Само изображения}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видеоклип}other{Само видеоклипове}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Само файл}other{Само файлове}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Приложението няма разрешение за записване, но може да записва звук чрез това USB устройство."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Лични"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Служебни"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Частно"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Личен изглед"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Служебен изглед"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Частен изглед"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Блокирано от системния ви администратор"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Това съдържание не може да се споделя със служебни приложения"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Това съдържание не може да се отваря със служебни приложения"</string> diff --git a/java/res/values-bn/strings.xml b/java/res/values-bn/strings.xml index 0561cf99..db656a0d 100644 --- a/java/res/values-bn/strings.xml +++ b/java/res/values-bn/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{লিঙ্ক সহ ভিডিও শেয়ার করা হচ্ছে}one{লিঙ্ক সহ #টি ভিডিও শেয়ার করা হচ্ছে}other{লিঙ্ক সহ #টি ভিডিও শেয়ার করা হচ্ছে}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{টেক্সট সহ ফাইল শেয়ার করা হচ্ছে}one{টেক্সট সহ #টি ফাইল শেয়ার করা হচ্ছে}other{টেক্সট সহ #টি ফাইল শেয়ার করা হচ্ছে}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{লিঙ্ক সহ ফাইল শেয়ার করা হচ্ছে}one{লিঙ্ক সহ #টি ফাইল শেয়ার করা হচ্ছে}other{লিঙ্ক সহ #টি ফাইল শেয়ার করা হচ্ছে}}"</string> + <string name="sharing_album" msgid="191743129899503345">"অ্যালবাম শেয়ার করা হচ্ছে"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{শুধু ছবি}one{শুধু ছবি}other{শুধু ছবি}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{শুধু ভিডিও}one{শুধু ভিডিও}other{শুধু ভিডিও}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{শুধু ফাইল}one{শুধু ফাইল}other{শুধু ফাইল}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"এই অ্যাপকে রেকর্ড করার অনুমতি দেওয়া হয়নি কিন্তু USB ডিভাইসের মাধ্যমে সেটি অডিও রেকর্ড করতে পারে।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ব্যক্তিগত"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"অফিস"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ব্যক্তিগত"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ব্যক্তিগত ভিউ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"অফিসের ভিউ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ব্যক্তিগত ভিউ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"আপনার আইটি অ্যাডমিন ব্লক করেছেন"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"অফিসের অ্যাপে এই কন্টেন্ট শেয়ার করা যাবে না"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"অফিসের অ্যাপে এই খোলা যাবে না"</string> diff --git a/java/res/values-bs/strings.xml b/java/res/values-bs/strings.xml index 3c88d9c1..d5f76fe7 100644 --- a/java/res/values-bs/strings.xml +++ b/java/res/values-bs/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Dijeljenje videozapisa putem linka}one{Dijeljenje # videozapisa putem linka}few{Dijeljenje # videozapisa putem linka}other{Dijeljenje # videozapisa putem linka}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Dijeljenje fajla putem poruke}one{Dijeljenje # fajla putem poruke}few{Dijeljenje # fajla putem poruke}other{Dijeljenje # fajlova putem poruke}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Dijeljenje fajla putem linka}one{Dijeljenje # fajla putem linka}few{Dijeljenje # fajla putem linka}other{Dijeljenje # fajlova putem linka}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Dijeljenje albuma"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}few{Samo slike}other{Samo slike}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo videozapis}one{Samo videozapisi}few{Samo videozapisi}other{Samo videozapisi}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo fajl}one{Samo fajlovi}few{Samo fajlovi}other{Samo fajlovi}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ovoj aplikaciji nije dato odobrenje za snimanje, ali može snimati zvuk putem ovog USB uređaja."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Lično"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Posao"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privatno"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Prikaz ličnog sadržaja"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Prikaz poslovnog sadržaja"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatan prikaz"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokirao je vaš IT administrator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ovaj sadržaj nije moguće dijeliti pomoću poslovnih aplikacija"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ovaj sadržaj nije moguće otvoriti pomoću poslovnih aplikacija"</string> diff --git a/java/res/values-ca/strings.xml b/java/res/values-ca/strings.xml index bd0416a5..a3407873 100644 --- a/java/res/values-ca/strings.xml +++ b/java/res/values-ca/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{# fitxer més}many{# de fitxers més}other{# fitxers més}}"</string> <string name="sharing_text" msgid="8137537443603304062">"S\'està compartint text"</string> <string name="sharing_link" msgid="2307694372813942916">"S\'està compartint un enllaç"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{S\'està compartint una imatge}many{S\'estan compartint # d\'imatges}other{S\'estan compartint # imatges}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Comparteix una imatge}many{Comparteix # d\'imatges}other{Comparteix # imatges}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{S\'està compartint un vídeo}many{S\'estan compartint # de vídeos}other{S\'estan compartint # vídeos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{S\'està compartint # fitxer}many{S\'estan compartint # de fitxers}other{S\'estan compartint # fitxers}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{S\'està compartint la imatge amb text}many{S\'estan compartint # d\'imatges amb text}other{S\'estan compartint # imatges amb text}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{S\'està compartint el vídeo amb un enllaç}many{S\'estan compartint # de vídeos amb un enllaç}other{S\'estan compartint # vídeos amb un enllaç}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{S\'està compartint el fitxer amb text}many{S\'estan compartint # de fitxers amb text}other{S\'estan compartint # fitxers amb text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{S\'està compartint el fitxer amb un enllaç}many{S\'estan compartint # de fitxers amb un enllaç}other{S\'estan compartint # fitxers amb un enllaç}}"</string> + <string name="sharing_album" msgid="191743129899503345">"S\'està compartint l\'àlbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Només imatge}many{Només imatges}other{Només imatges}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Només vídeo}many{Només vídeos}other{Només vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Només fitxer}many{Només fitxers}other{Només fitxers}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aquesta aplicació no té permís de gravació, però pot capturar àudio a través d\'aquest dispositiu USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Feina"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualització personal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualització de treball"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualització privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloquejat per l\'administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"No es pot compartir aquest contingut amb aplicacions de treball"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"No es pot obrir aquest contingut amb aplicacions de treball"</string> diff --git a/java/res/values-cs/strings.xml b/java/res/values-cs/strings.xml index a5deed60..93712487 100644 --- a/java/res/values-cs/strings.xml +++ b/java/res/values-cs/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sdílení videa s odkazem}few{Sdílení # videí s odkazem}many{Sdílení # videa s odkazem}other{Sdílení # videí s odkazem}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sdílení souboru s textem}few{Sdílení # souborů s textem}many{Sdílení # souboru s textem}other{Sdílení # souborů s textem}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sdílení souboru s odkazem}few{Sdílení # souborů s odkazem}many{Sdílení # souboru s odkazem}other{Sdílení # souborů s odkazem}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sdílení alba"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Pouze obrázek}few{Pouze obrázky}many{Pouze obrázky}other{Pouze obrázky}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Pouze video}few{Pouze videa}many{Pouze videa}other{Pouze videa}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Pouze soubor}few{Pouze soubory}many{Pouze soubory}other{Pouze soubory}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Tato aplikace nemá oprávnění k nahrávání, ale může zaznamenávat zvuk prostřednictvím tohoto zařízení USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osobní"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Pracovní"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Soukromé"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Osobní zobrazení"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pracovní zobrazení"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Soukromé zobrazení"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokováno administrátorem IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tento obsah nelze sdílet pomocí pracovních aplikací"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tento obsah nelze otevřít pomocí pracovních aplikací"</string> diff --git a/java/res/values-da/strings.xml b/java/res/values-da/strings.xml index 8d226d44..26385908 100644 --- a/java/res/values-da/strings.xml +++ b/java/res/values-da/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deler video med et link}one{Deler # video med et link}other{Deler # videoer med et link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deler fil med tekst}one{Deler # fil med tekst}other{Deler # filer med tekst}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deler fil med et link}one{Deler # fil med et link}other{Deler # filer med et link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deling af albummet"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Kun billedet}one{Kun billedet}other{Kun billeder}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Kun video}one{Kun video}other{Kun videoer}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Kun filen}one{Kun filen}other{Kun filer}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Denne app har ikke fået tilladelse til at optage, men optager muligvis lyd via denne USB-enhed."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personlig"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Arbejde"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visningen Personligt"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visningen Arbejde"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visningen Privat"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokeret af din it-administrator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Dette indhold kan ikke deles med arbejdsapps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Dette indhold kan ikke åbnes med arbejdsapps"</string> diff --git a/java/res/values-de/strings.xml b/java/res/values-de/strings.xml index dc476fa7..67ea5e63 100644 --- a/java/res/values-de/strings.xml +++ b/java/res/values-de/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Video wird per Link geteilt}other{# Videos werden per Link geteilt}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Datei wird per SMS geteilt}other{# Dateien werden per SMS geteilt}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Datei wird per Link geteilt}other{# Dateien werden per Link geteilt}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Album teilen"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Nur Bild}other{Nur Bilder}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Nur Video}other{Nur Videos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Nur Datei}other{Nur Dateien}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Diese App hat noch keine Berechtigung zum Aufnehmen erhalten, könnte aber Audioaufnahmen über dieses USB-Gerät machen."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Privat"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Geschäftlich"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Private Ansicht"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Geschäftliche Ansicht"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private Ansicht"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Von deinem IT-Administrator blockiert"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Diese Art von Inhalt kann nicht über geschäftliche Apps geteilt werden"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Diese Art von Inhalt kann nicht mit geschäftlichen Apps geöffnet werden"</string> diff --git a/java/res/values-el/strings.xml b/java/res/values-el/strings.xml index e760e00c..f88dc751 100644 --- a/java/res/values-el/strings.xml +++ b/java/res/values-el/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Κοινοποίηση βίντεο με σύνδεσμο}other{Κοινοποίηση # βίντεο με σύνδεσμο}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Κοινοποίηση αρχείου με κείμενο}other{Κοινοποίηση # αρχείων με κείμενο}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Κοινοποίηση αρχείου με σύνδεσμο}other{Κοινοποίηση # αρχείων με σύνδεσμο}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Κοινοποίηση λευκώματος"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Μόνο εικόνα}other{Μόνο εικόνες}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Μόνο βίντεο}other{Μόνο βίντεο}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Μόνο αρχείο}other{Μόνο αρχεία}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Δεν έχει εκχωρηθεί άδεια εγγραφής σε αυτή την εφαρμογή, αλλά μέσω αυτής της συσκευής USB θα μπορεί να εγγράφει ήχο."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Προσωπικό"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Εργασία"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Ιδιωτική"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Προσωπική προβολή"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Προβολή εργασίας"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Ιδιωτική προβολή"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Αποκλείστηκε από τον διαχειριστή IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Δεν είναι δυνατή η κοινοποίηση αυτού του περιεχομένου με εφαρμογές εργασιών"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με εφαρμογές εργασιών"</string> diff --git a/java/res/values-en-rAU/strings.xml b/java/res/values-en-rAU/strings.xml index a1438ed9..90f6974a 100644 --- a/java/res/values-en-rAU/strings.xml +++ b/java/res/values-en-rAU/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> diff --git a/java/res/values-en-rCA/strings.xml b/java/res/values-en-rCA/strings.xml index a1438ed9..90f6974a 100644 --- a/java/res/values-en-rCA/strings.xml +++ b/java/res/values-en-rCA/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> diff --git a/java/res/values-en-rGB/strings.xml b/java/res/values-en-rGB/strings.xml index a1438ed9..90f6974a 100644 --- a/java/res/values-en-rGB/strings.xml +++ b/java/res/values-en-rGB/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> diff --git a/java/res/values-en-rIN/strings.xml b/java/res/values-en-rIN/strings.xml index a1438ed9..90f6974a 100644 --- a/java/res/values-en-rIN/strings.xml +++ b/java/res/values-en-rIN/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> diff --git a/java/res/values-en-rXC/strings.xml b/java/res/values-en-rXC/strings.xml index 56574b6c..f0650785 100644 --- a/java/res/values-en-rXC/strings.xml +++ b/java/res/values-en-rXC/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> diff --git a/java/res/values-es-rUS/strings.xml b/java/res/values-es-rUS/strings.xml index 97ae9a6c..e79f383b 100644 --- a/java/res/values-es-rUS/strings.xml +++ b/java/res/values-es-rUS/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartir video con vínculo}many{Compartir # de videos con vínculo}other{Compartir # videos con vínculo}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartir archivo con texto}many{Compartir # de archivos con texto}other{Compartir # archivos con texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartir archivo con vínculo}many{Compartir # de archivos con vínculo}other{Compartir # archivos con vínculo}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Se comparte este álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Solo imagen}many{Solo imágenes}other{Solo imágenes}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Solo video}many{Solo videos}other{Solo videos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Solo archivo}many{Solo archivos}other{Solo archivos}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aunque no se le otorgó permiso de grabación a esta app, puede capturar audio con este dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabajo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privada"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vista personal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vista de trabajo"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqueado por tu administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"No se pueden usar apps de trabajo para compartir este contenido"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"No se puede abrir este contenido con apps de trabajo"</string> diff --git a/java/res/values-es/strings.xml b/java/res/values-es/strings.xml index 0c42bb82..28b37a73 100644 --- a/java/res/values-es/strings.xml +++ b/java/res/values-es/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{y # archivo más}many{y # archivos más}other{y # archivos más}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Compartiendo texto"</string> <string name="sharing_link" msgid="2307694372813942916">"Compartiendo enlace"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartiendo imagen}many{Compartiendo # imágenes}other{Compartiendo # imágenes}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartir imagen}many{Compartir # imágenes}other{Compartir # imágenes}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartiendo vídeo}many{Compartiendo # vídeos}other{Compartiendo # vídeos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartiendo # archivo}many{Compartiendo # archivos}other{Compartiendo # archivos}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartiendo imagen con texto}many{Compartiendo # imágenes con texto}other{Compartiendo # imágenes con texto}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartiendo vídeo con enlace}many{Compartiendo # vídeos con enlace}other{Compartiendo # vídeos con enlace}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartiendo archivo con mensaje de texto}many{Compartiendo # archivos con mensaje de texto}other{Compartiendo # archivos con mensaje de texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartiendo archivo con enlace}many{Compartiendo # archivos con enlace}other{Compartiendo # archivos con enlace}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Compartiendo álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Solo imagen}many{Solo imágenes}other{Solo imágenes}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Solo vídeo}many{Solo vídeos}other{Solo vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Solo archivo}many{Solo archivos}other{Solo archivos}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Esta aplicación no tiene permiso para grabar, pero podría capturar audio con este dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabajo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privada"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Ver contenido personal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ver contenido de trabajo"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqueado por tu administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Este contenido no se puede compartir con aplicaciones de trabajo"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Este contenido no se puede abrir con aplicaciones de trabajo"</string> @@ -95,5 +98,5 @@ <string name="include_text" msgid="642280283268536140">"Incluir texto"</string> <string name="exclude_link" msgid="1332778255031992228">"Excluir enlace"</string> <string name="include_link" msgid="827855767220339802">"Incluir enlace"</string> - <string name="pinned" msgid="7623664001331394139">"Fijada"</string> + <string name="pinned" msgid="7623664001331394139">"Fijado"</string> </resources> diff --git a/java/res/values-et/strings.xml b/java/res/values-et/strings.xml index bc960699..9b4fff07 100644 --- a/java/res/values-et/strings.xml +++ b/java/res/values-et/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Linki sisaldava video jagamine}other{# linki sisaldava video jagamine}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Teksti sisaldava faili jagamine}other{# teksti sisaldava faili jagamine}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Linki sisaldava faili jagamine}other{# linki sisaldava faili jagamine}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albumi jagamine"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Ainult pilt}other{Ainult pildid}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Ainult video}other{Ainult videod}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Ainult fail}other{Ainult failid}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Sellele rakendusele pole antud salvestamise luba, kuid see saab heli jäädvustada selle USB-seadme kaudu."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Isiklik"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Töö"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privaatne"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Isiklik vaade"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Töövaade"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privaatne vaade"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokeeris teie IT-administraator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Seda sisu ei saa töörakendustega jagada"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Seda sisu ei saa töörakendustega avada"</string> diff --git a/java/res/values-eu/strings.xml b/java/res/values-eu/strings.xml index 1cc7576b..275eb854 100644 --- a/java/res/values-eu/strings.xml +++ b/java/res/values-eu/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Bideo estekadun bat partekatuko da}other{# bideo estekadun partekatuko dira}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Fitxategi testudun bat partekatuko da}other{# fitxategi testudun partekatuko dira}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Fitxategi estekadun bat partekatuko da}other{# fitxategi estekadun partekatuko dira}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albuma partekatzea"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Irudia soilik}other{Irudiak bakarrik}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Bideoa soilik}other{Bideoak soilik}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fitxategia soilik}other{Fitxategiak soilik}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aplikazioak ez du grabatzeko baimenik, baina baliteke audioa grabatzea USB bidezko gailu horren bidez."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pertsonala"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Lanekoa"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Pribatua"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Ikuspegi pertsonala"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Laneko ikuspegia"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Ikuspegi pribatua"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IKT saileko administratzaileak blokeatu egin du"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Eduki hau ezin da laneko aplikazioekin partekatu"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Eduki hau ezin da laneko aplikazioekin ireki"</string> diff --git a/java/res/values-fa/strings.xml b/java/res/values-fa/strings.xml index 58313f70..82a75582 100644 --- a/java/res/values-fa/strings.xml +++ b/java/res/values-fa/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{درحال همرسانی ویدیو با پیوند}one{درحال همرسانی # ویدیو با پیوند}other{درحال همرسانی # ویدیو با پیوند}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{درحال همرسانی فایل با نوشتار}one{درحال همرسانی # فایل با نوشتار}other{درحال همرسانی # فایل با نوشتار}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{درحال همرسانی فایل با پیوند}one{درحال همرسانی # فایل با پیوند}other{درحال همرسانی # فایل با پیوند}}"</string> + <string name="sharing_album" msgid="191743129899503345">"همرسانی آلبوم"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{فقط تصویر}one{فقط تصویر}other{فقط تصویر}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{فقط ویدیو}one{فقط ویدیو}other{فقط ویدیو}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{فقط فایل}one{فقط فایل}other{فقط فایل}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"مجوز ضبط به این برنامه داده نشده است اما میتواند صدا را ازطریق این دستگاه USB ضبط کند."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"شخصی"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"کاری"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"خصوصی"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"نمای شخصی"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"نمای کاری"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"نمای خصوصی"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"سرپرست فناوری اطلاعات آن را مسدود کرده است"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"نمیتوان این محتوا را با برنامههای کاری همرسانی کرد"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"نمیتوان این محتوا را با برنامههای کاری باز کرد"</string> diff --git a/java/res/values-fi/strings.xml b/java/res/values-fi/strings.xml index 53537e67..80f39cfa 100644 --- a/java/res/values-fi/strings.xml +++ b/java/res/values-fi/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Videota ja linkkiä jaetaan}other{# videota ja linkkiä jaetaan}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Tiedostoa ja tekstiä jaetaan}other{# tiedostoa ja tekstiä jaetaan}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Tiedostoa ja linkkiä jaetaan}other{# tiedostoa ja linkkiä jaetaan}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albumia jaetaan"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Vain kuva}other{Vain kuvat}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vain video}other{Vain videot}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Vain tiedostot}other{Vain tiedostot}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Sovellus ei ole saanut tallennuslupaa mutta voi tallentaa ääntä tämän USB-laitteen avulla."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Henkilökohtainen"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Työ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Yksityinen"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Henkilökohtainen näkymä"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Työnäkymä"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Yksityinen näkymä"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT-järjestelmänvalvojasi estämä"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tätä sisältöä ei voi jakaa työsovelluksilla"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tätä sisältöä ei voi avata työsovelluksilla"</string> diff --git a/java/res/values-fr-rCA/strings.xml b/java/res/values-fr-rCA/strings.xml index 5595b6cc..d5064071 100644 --- a/java/res/values-fr-rCA/strings.xml +++ b/java/res/values-fr-rCA/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Partage d\'une vidéo avec un lien}one{Partage de # vidéo avec un lien}many{Partage de # de vidéos avec un lien}other{Partage de # vidéos avec un lien}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Partage d\'un fichier avec du texte}one{Partage de # fichier avec du texte}many{Partage de # de fichiers avec du texte}other{Partage de # fichiers avec du texte}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Partage d\'un fichier avec un lien}one{Partage de # fichier avec un lien}many{Partage de # de fichiers avec un lien}other{Partage de # fichiers avec un lien}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Album partagé"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image uniquement}one{Image uniquement}many{Images uniquement}other{Images uniquement}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vidéo uniquement}one{Vidéo uniquement}many{Vidéos uniquement}other{Vidéos uniquement}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fichier uniquement}one{Fichier uniquement}many{Fichiers uniquement}other{Fichiers uniquement}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Cette application n\'a pas été autorisée à effectuer des enregistrements, mais elle pourrait capturer du contenu audio par l\'intermédiaire de cet appareil USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personnel"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Professionnel"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privé"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Affichage personnel"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Affichage professionnel"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Affichage privé"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqué par votre administrateur informatique"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Impossible de partager ce contenu avec des applications professionnelles"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Impossible d\'ouvrir ce contenu avec des applications professionnelles"</string> diff --git a/java/res/values-fr/strings.xml b/java/res/values-fr/strings.xml index 5f0c85e0..ff0c5199 100644 --- a/java/res/values-fr/strings.xml +++ b/java/res/values-fr/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Partager 1 vidéo avec un lien}one{Partager # vidéo avec un lien}many{Partager # vidéos avec un lien}other{Partager # vidéos avec un lien}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Partager 1 fichier avec du texte}one{Partager # fichier avec du texte}many{Partager # fichiers avec du texte}other{Partager # fichiers avec du texte}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Partager 1 fichier avec un lien}one{Partager # fichier avec un lien}many{Partager # fichiers avec un lien}other{Partager # fichiers avec un lien}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Partage de l\'album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image uniquement}one{Image uniquement}many{Images uniquement}other{Images uniquement}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vidéo uniquement}one{Vidéo uniquement}many{Vidéos uniquement}other{Vidéos uniquement}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fichier uniquement}one{Fichier uniquement}many{Fichiers uniquement}other{Fichiers uniquement}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Cette application n\'a pas reçu l\'autorisation d\'enregistrer des contenus audio, mais peut le faire via ce périphérique USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personnel"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Professionnel"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Mode privé"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vue personnelle"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vue professionnelle"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Affichage en mode privé"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqué par votre administrateur informatique"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Impossible de partager ce contenu avec des applis professionnelles"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Impossible d\'ouvrir ce contenu avec des applis professionnelles"</string> diff --git a/java/res/values-gl/strings.xml b/java/res/values-gl/strings.xml index 60dc78de..f4727877 100644 --- a/java/res/values-gl/strings.xml +++ b/java/res/values-gl/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartindo vídeo con ligazón}other{Compartindo # vídeos con ligazón}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartindo ficheiro con texto}other{Compartindo # ficheiros con texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartindo ficheiro con ligazón}other{Compartindo # ficheiros con ligazón}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Compartindo álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Só a imaxe}other{Só as imaxes}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Só o vídeo}other{Só os vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Só o ficheiro}other{Só os ficheiros}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Esta aplicación non está autorizada a realizar gravacións, pero podería capturar audio a través deste dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Persoal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Traballo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privada"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vista persoal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vista de traballo"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"O teu administrador de TI bloqueou a instalación"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Este contido non pode compartirse con aplicacións do traballo"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Este contido non pode abrirse con aplicacións do traballo"</string> diff --git a/java/res/values-gu/strings.xml b/java/res/values-gu/strings.xml index db3bd59a..22826860 100644 --- a/java/res/values-gu/strings.xml +++ b/java/res/values-gu/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{લિંક સાથે વીડિયો શેર કરી રહ્યાં છીએ}one{લિંક સાથે # વીડિયો શેર કરી રહ્યાં છીએ}other{લિંક સાથે # વીડિયો શેર કરી રહ્યાં છીએ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ટેક્સ્ટ સાથે ફાઇલ શેર કરી રહ્યાં છીએ}one{ટેક્સ્ટ સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}other{ટેક્સ્ટ સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{લિંક સાથે ફાઇલ શેર કરી રહ્યાં છીએ}one{લિંક સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}other{લિંક સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"આલ્બમ શેર કરી રહ્યાં છીએ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{માત્ર છબી}one{માત્ર છબી}other{માત્ર છબીઓ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ફક્ત વીડિયો}one{ફક્ત વીડિયો}other{ફક્ત વીડિયો}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ફક્ત ફાઇલ}one{ફક્ત ફાઇલ}other{ફક્ત ફાઇલો}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"આ ઍપને રેકૉર્ડ કરવાની પરવાનગી આપવામાં આવી નથી પરંતુ તે આ USB ડિવાઇસ મારફતે ઑડિયો કૅપ્ચર કરી શકે છે."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"વ્યક્તિગત"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ઑફિસ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ખાનગી"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"વ્યક્તિગત વ્યૂ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ઑફિસ વ્યૂ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ખાનગી વ્યૂ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"તમારા IT વ્યવસ્થાપકે બ્લૉક કર્યું છે"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"આ કન્ટેન્ટ ઑફિસ માટેની ઍપ સાથે શેર કરી શકાતું નથી"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"આ કન્ટેન્ટ ઑફિસ માટેની ઍપ વડે ખોલી શકાતું નથી"</string> diff --git a/java/res/values-hi/strings.xml b/java/res/values-hi/strings.xml index b722e0ce..933956bd 100644 --- a/java/res/values-hi/strings.xml +++ b/java/res/values-hi/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{लिंक के साथ वीडियो शेयर किया जा रहा है}one{लिंक के साथ # वीडियो शेयर किया जा रहा है}other{लिंक के साथ # वीडियो शेयर किए जा रहे हैं}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{टेक्स्ट के साथ फ़ाइल शेयर की जा रही है}one{टेक्स्ट के साथ # फ़ाइल शेयर की जा रही है}other{टेक्स्ट के साथ # फ़ाइलें शेयर की जा रही हैं}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{लिंक के साथ फ़ाइल शेयर की जा रही है}one{लिंक के साथ # फ़ाइल शेयर की जा रही है}other{लिंक के साथ # फ़ाइलें शेयर की जा रही हैं}}"</string> + <string name="sharing_album" msgid="191743129899503345">"एल्बम शेयर किया जा रहा है"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{सिर्फ़ इमेज}one{सिर्फ़ इमेज}other{सिर्फ़ इमेज}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{सिर्फ़ वीडियो}one{सिर्फ़ वीडियो}other{सिर्फ़ वीडियो}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{सिर्फ़ फ़ाइल}one{सिर्फ़ फ़ाइल}other{सिर्फ़ फ़ाइलें}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"इस ऐप्लिकेशन को रिकॉर्ड करने की अनुमति नहीं दी गई है. हालांकि, ऐप्लिकेशन इस यूएसबी डिवाइस से ऐसा कर सकता है."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"निजी प्रोफ़ाइल"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"वर्क प्रोफ़ाइल"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"निजी"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"निजी व्यू"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"वर्क व्यू"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"निजी व्यू"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"आपके आईटी एडमिन ने इस कॉन्टेंट को शेयर करने की सुविधा ब्लॉक कर रखी है"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"इस कॉन्टेंट को ऑफ़िस के काम से जुड़े ऐप्लिकेशन का इस्तेमाल करके, शेयर नहीं किया जा सकता"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"इस कॉन्टेंट को ऑफ़िस के काम से जुड़े ऐप्लिकेशन पर खोला नहीं जा सकता"</string> diff --git a/java/res/values-hr/strings.xml b/java/res/values-hr/strings.xml index e2d71b37..853deacb 100644 --- a/java/res/values-hr/strings.xml +++ b/java/res/values-hr/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{i još # datoteka}one{i još # datoteka}few{i još # datoteke}other{i još # datoteka}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Dijeli se tekst"</string> <string name="sharing_link" msgid="2307694372813942916">"Dijeli se veza"</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{Podijelite sliku}one{Podijelite # sliku}few{Podijelite # slike}other{Podijelite # slika}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Dijeli se videozapis}one{Dijeli se # videozapis}few{Dijele se # videozapisa}other{Dijeli se # videozapisa}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Dijeli se # datoteka}one{Dijeli se # datoteka}few{Dijele se # datoteke}other{Dijeli se # datoteka}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Dijeli se slika s tekstom}one{Dijeli se # slika s tekstom}few{Dijele se # slike s tekstom}other{Dijeli se # slika s tekstom}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Dijeli se videozapis s vezom}one{Dijeli se # videozapis s vezom}few{Dijele se # videozapisa s vezom}other{Dijeli se # videozapisa s vezom}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Dijeli se datoteka s tekstom}one{Dijeli se # datoteka s tekstom}few{Dijele se # datoteke s tekstom}other{Dijeli se # datoteka s tekstom}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Dijeli se datoteka s vezom}one{Dijeli se # datoteka s vezom}few{Dijele se # datoteke s vezom}other{Dijeli se # datoteka s vezom}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Dijeljenje albuma"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}few{Samo slike}other{Samo slike}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo videozapis}one{Samo videozapisi}few{Samo videozapisi}other{Samo videozapisi}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo datoteka}one{Samo datoteke}few{Samo datoteke}other{Samo datoteke}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ta aplikacija nema dopuštenje za snimanje, no mogla bi primati zvuk putem ovog USB uređaja."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osobno"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Posao"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privatno"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Osobni prikaz"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Poslovni prikaz"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatni prikaz"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokirao vaš IT administrator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Taj se sadržaj ne može dijeliti pomoću poslovnih aplikacija"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Taj se sadržaj ne može otvoriti pomoću poslovnih aplikacija"</string> diff --git a/java/res/values-hu/strings.xml b/java/res/values-hu/strings.xml index 53ddba7f..fa4f23d2 100644 --- a/java/res/values-hu/strings.xml +++ b/java/res/values-hu/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Videó megosztása linkkel}other{# videó megosztása linkkel}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Fájl megosztása szöveggel}other{# fájl megosztása szöveggel}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Fájl megosztása linkkel}other{# fájl megosztása linkkel}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Album megosztása"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Csak kép}other{Csak képek}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Csak videó}other{Csak videók}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Csak fájl}other{Csak fájlok}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ez az alkalmazás nem rendelkezik rögzítési engedéllyel, de ezzel az USB-eszközzel képes a hangfelvételre."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Személyes"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Munka"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privát"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Személyes nézet"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Munkanézet"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privát nézet"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Rendszergazda által letiltva"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ez a tartalom nem osztható meg munkahelyi alkalmazásokkal"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ez a tartalom nem nyitható meg munkahelyi alkalmazásokkal"</string> diff --git a/java/res/values-hy/strings.xml b/java/res/values-hy/strings.xml index 6a83cdaa..ec8d1355 100644 --- a/java/res/values-hy/strings.xml +++ b/java/res/values-hy/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Տեսանյութի ուղարկում հղման միջոցով}one{# տեսանյութի ուղարկում հղման միջոցով}other{# տեսանյութի ուղարկում հղման միջոցով}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Ֆայլի ուղարկում տեքստային հաղորդագրության միջոցով}one{# ֆայլի ուղարկում տեքստային հաղորդագրության միջոցով}other{# ֆայլի ուղարկում տեքստային հաղորդագրության միջոցով}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Ֆայլի ուղարկում հղման միջոցով}one{# ֆայլի ուղարկում հղման միջոցով}other{# ֆայլի ուղարկում հղման միջոցով}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Ալբոմը դարձվում է ընդհանուր"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Միայն պատկերը}one{Միայն պատկերը}other{Միայն պատկերները}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Միայն տեսանյութը}one{Միայն տեսանյութը}other{Միայն տեսանյութերը}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Միայն ֆայլը}one{Միայն ֆայլը}other{Միայն ֆայլերը}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Հավելվածը ձայնագրելու թույլտվություն չունի, սակայն կկարողանա գրանցել ձայնն այս USB սարքի միջոցով։"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Անձնական"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Աշխատանքային"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Անձնական"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Անձնական"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Աշխատանքային"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Անձնական դիտակերպ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Արգելափակվել է ձեր ՏՏ ադմինիստրատորի կողմից"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Այս բովանդակությունը հնարավոր չէ ուղարկել աշխատանքային հավելվածներով"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Այս բովանդակությունը հնարավոր չէ բացել աշխատանքային հավելվածներով"</string> diff --git a/java/res/values-in/strings.xml b/java/res/values-in/strings.xml index d7400b80..83d18123 100644 --- a/java/res/values-in/strings.xml +++ b/java/res/values-in/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Membagikan video dengan link}other{Membagikan # video dengan link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Membagikan file dengan teks}other{Membagikan # file dengan teks}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Membagikan file dengan link}other{Membagikan # file dengan link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Berbagi album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Khusus gambar}other{Khusus gambar}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Khusus video}other{Khusus video}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Khusus file}other{Khusus file}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aplikasi ini tidak diberi izin merekam, tetapi dapat merekam audio melalui perangkat USB ini."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pribadi"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Kerja"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Pribadi"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Tampilan pribadi"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Tampilan kerja"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Tampilan pribadi"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Diblokir oleh admin IT Anda"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Konten ini tidak dapat dibagikan dengan aplikasi kerja"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Konten ini tidak dapat dibuka dengan aplikasi kerja"</string> diff --git a/java/res/values-is/strings.xml b/java/res/values-is/strings.xml index 8e0a9f4f..a07fc1c8 100644 --- a/java/res/values-is/strings.xml +++ b/java/res/values-is/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deilir myndskeiði með tengli}one{Deilir # myndskeiði með tengli}other{Deilir # myndskeiðum með tengli}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deilir skrá með texta}one{Deilir # skrá með texta}other{Deilir # skrám með texta}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deilir skrá með tengli}one{Deilir # skrá með tengli}other{Deilir # skrám með tengli}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deilir albúmi"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Eingöngu mynd}one{Eingöngu myndir}other{Eingöngu myndir}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Eingöngu myndskeið}one{Eingöngu myndskeið}other{Eingöngu myndskeið}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Eingöngu skrá}one{Eingöngu skrár}other{Eingöngu skrár}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Þetta forrit hefur ekki fengið heimild fyrir upptöku en gæti tekið upp hljóð í gegnum þetta USB-tæki."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Persónulegt"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Vinna"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Lokað"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Persónulegt yfirlit"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vinnuyfirlit"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Lokuð stilling"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Útilokað af kerfisstjóra"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ekki er hægt að deila þessu efni með vinnuforritum"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ekki er hægt að opna þetta efni með vinnuforritum"</string> diff --git a/java/res/values-it/strings.xml b/java/res/values-it/strings.xml index 38aba0c2..a68fe2bb 100644 --- a/java/res/values-it/strings.xml +++ b/java/res/values-it/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Condivisione video con link in corso…}many{Condivisione # video con link in corso…}other{Condivisione # video con link in corso…}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Condivisione file con messaggio in corso…}many{Condivisione # file con messaggio in corso…}other{Condivisione # file con messaggio in corso…}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Condivisione file con link in corso…}many{Condivisione # file con link in corso…}other{Condivisione # file con link in corso…}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Condivisione album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Soltanto l\'immagine}many{Soltanto le immagini}other{Soltanto le immagini}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Soltanto il video}many{Soltanto i video}other{Soltanto i video}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Soltanto il file}many{Soltanto i file}other{Soltanto i file}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"A questa app non è stata concessa l\'autorizzazione di registrazione, ma l\'app potrebbe acquisire l\'audio tramite questo dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personale"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Lavoro"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privata"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualizzazione personale"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualizzazione di lavoro"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualizzazione privata"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloccati dall\'amministratore IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Questi contenuti non possono essere condivisi con app di lavoro"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Questi contenuti non possono essere aperti con app di lavoro"</string> diff --git a/java/res/values-iw/strings.xml b/java/res/values-iw/strings.xml index c79425d8..30ecfe02 100644 --- a/java/res/values-iw/strings.xml +++ b/java/res/values-iw/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{שיתוף סרטון עם קישור}one{שיתוף # סרטונים עם קישור}two{שיתוף # סרטונים עם קישור}other{שיתוף # סרטונים עם קישור}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{שיתוף קובץ עם טקסט}one{שיתוף # קבצים עם טקסט}two{שיתוף # קבצים עם טקסט}other{שיתוף # קבצים עם טקסט}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{שיתוף תמונה עם קישור}one{שיתוף # תמונות עם קישור}two{שיתוף # תמונות עם קישור}other{שיתוף # תמונות עם קישור}}"</string> + <string name="sharing_album" msgid="191743129899503345">"שיתוף האלבום"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{תמונה בלבד}one{תמונות בלבד}two{תמונות בלבד}other{תמונות בלבד}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{סרטון בלבד}one{סרטונים בלבד}two{סרטונים בלבד}other{סרטונים בלבד}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{קובץ בלבד}one{קבצים בלבד}two{קבצים בלבד}other{קבצים בלבד}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"לאפליקציה זו לא ניתנה הרשאת הקלטה, אבל אפשר להקליט אודיו באמצעות התקן ה-USB הזה."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"אישי"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"עבודה"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"פרטי"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"תצוגה אישית"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"תצוגת עבודה"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"תצוגה פרטית"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"נחסם על ידי מנהל ה-IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"אי אפשר לשתף את התוכן הזה עם אפליקציות לעבודה"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"אי אפשר לפתוח את התוכן הזה באמצעות אפליקציות לעבודה"</string> diff --git a/java/res/values-ja/strings.xml b/java/res/values-ja/strings.xml index 15c2277b..a9f79a48 100644 --- a/java/res/values-ja/strings.xml +++ b/java/res/values-ja/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{リンク付き動画を共有中}other{リンク付き動画を # 件共有中}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{テキスト付きファイルを共有中}other{テキスト付きファイルを # 件共有中}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{リンク付きファイルを共有中}other{リンク付きファイルを # 件共有中}}"</string> + <string name="sharing_album" msgid="191743129899503345">"アルバムの共有"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{画像のみ}other{画像のみ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{動画のみ}other{動画のみ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ファイルのみ}other{ファイルのみ}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"このアプリに録音権限は付与されていませんが、この USB デバイスから音声を収集できるようになります。"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"個人用"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"仕事用"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"プライベート"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"個人用ビュー"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"仕事用ビュー"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"プライベート ビュー"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT 管理者によりブロックされました"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"このコンテンツを仕事用アプリと共有することはできません"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"このコンテンツを仕事用アプリで開くことはできません"</string> diff --git a/java/res/values-ka/strings.xml b/java/res/values-ka/strings.xml index 88bc15ac..86991fab 100644 --- a/java/res/values-ka/strings.xml +++ b/java/res/values-ka/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ვიდეო ზიარდება ბმულით}other{# ვიდეო ზიარდება ბმულით}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ფაილი ზიარდება ტექსტით}other{# ფაილი ზიარდება ტექსტით}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ფაილი ზიარდება ბმულით}other{# ფაილი ზიარდება ბმულით}}"</string> + <string name="sharing_album" msgid="191743129899503345">"გაზიარებული ალბომი"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{მხოლოდ სურათი}other{მხოლოდ სურათები}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{მხოლოდ ვიდეო}other{მხოლოდ ვიდეოები}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{მხოლოდ ფაილი}other{მხოლოდ ფაილები}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ამ აპს არ აქვს მინიჭებული ჩაწერის ნებართვა, მაგრამ შეუძლია ჩაიწეროს აუდიო ამ USB მოწყობილობის მეშვეობით."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"პირადი"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"სამსახური"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"კერძო"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"პირადი ხედი"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"სამსახურის ხედი"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"პირადი სივრცე"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"დაბლოკილია თქვენი IT-ადმინისტრატორის მიერ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ამ კონტენტის სამსახურის აპებისთვის გაზიარება შეუძლებელია"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ამ კონტენტის სამსახურის აპებით გახსნა შეუძლებელია"</string> diff --git a/java/res/values-kk/strings.xml b/java/res/values-kk/strings.xml index 7b195799..2ac5fa0e 100644 --- a/java/res/values-kk/strings.xml +++ b/java/res/values-kk/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Сілтемесі бар бейне жіберу}other{Сілтемесі бар # бейне жіберу}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Мәтіні бар файл жіберу}other{Мәтіні бар # файл жіберу}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Сілтемесі бар файл жіберу}other{Сілтемесі бар # файл жіберу}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Альбомды бөлісу"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Тек сурет}other{Тек суреттер}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Тек бейне}other{Тек бейнелер}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Тек файл}other{Тек файлдар}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Қолданбаға жазу рұқсаты берілмеді, бірақ ол осы USB құрылғысы арқылы дыбыс жаза алады."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Жеке"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Жұмыс"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Құпия"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Жеке көру"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Жұмыс деректерін көру"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Құпия көрініс"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Әкімшіңіз бөгеген"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Бұл контентті жұмыс қолданбаларымен бөлісу мүмкін емес."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Бұл контентті жұмыс қолданбаларымен ашу мүмкін емес."</string> diff --git a/java/res/values-km/strings.xml b/java/res/values-km/strings.xml index ae956af3..f0f25e41 100644 --- a/java/res/values-km/strings.xml +++ b/java/res/values-km/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ចែករំលែកវីដេអូជាមួយតំណ}other{ចែករំលែក # វីដេអូជាមួយតំណ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ចែករំលែកឯកសារជាមួយអក្សរ}other{ចែករំលែក # ឯកសារជាមួយអក្សរ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ចែករំលែកឯកសារជាមួយតំណ}other{ចែករំលែកឯកសារ # ជាមួយតំណ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"កំពុងចែករំលែកអាល់ប៊ុម"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{រូបភាពតែប៉ុណ្ណោះ}other{រូបភាពតែប៉ុណ្ណោះ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{វីដេអូតែប៉ុណ្ណោះ}other{វីដេអូតែប៉ុណ្ណោះ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ឯកសារតែប៉ុណ្ណោះ}other{ឯកសារតែប៉ុណ្ណោះ}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"កម្មវិធីនេះមិនទាន់បានទទួលសិទ្ធិថតសំឡេងនៅឡើយទេ ប៉ុន្តែអាចថតសំឡេងតាមរយៈឧបករណ៍ USB នេះបាន។"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ផ្ទាល់ខ្លួន"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ការងារ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ឯកជន"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ទិដ្ឋភាពផ្ទាល់ខ្លួន"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ទិដ្ឋភាពការងារ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ទិដ្ឋភាពឯកជន"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"បានទប់ស្កាត់ដោយអ្នកគ្រប់គ្រងផ្នែកព័ត៌មានវិទ្យារបស់អ្នក"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ខ្លឹមសារនេះមិនអាចចែករំលែកតាមរយៈកម្មវិធីការងារបានទេ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ខ្លឹមសារនេះមិនអាចបើកតាមរយៈកម្មវិធីការងារបានទេ"</string> diff --git a/java/res/values-kn/strings.xml b/java/res/values-kn/strings.xml index 505277c6..101a1bc0 100644 --- a/java/res/values-kn/strings.xml +++ b/java/res/values-kn/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ಲಿಂಕ್ನೊಂದಿಗೆ ವೀಡಿಯೊವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಲಿಂಕ್ನೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಲಿಂಕ್ನೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ಪಠ್ಯದೊಂದಿಗೆ ಫೈಲ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಪಠ್ಯದೊಂದಿಗೆ # ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಪಠ್ಯದೊಂದಿಗೆ # ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ಲಿಂಕ್ನೊಂದಿಗೆ ಫೈಲ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಲಿಂಕ್ನೊಂದಿಗೆ # ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಲಿಂಕ್ನೊಂದಿಗೆ # ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ಆಲ್ಬಮ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ಚಿತ್ರ ಮಾತ್ರ}one{ಚಿತ್ರಗಳು ಮಾತ್ರ}other{ಚಿತ್ರಗಳು ಮಾತ್ರ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ವೀಡಿಯೊ ಮಾತ್ರ}one{ವೀಡಿಯೊಗಳು ಮಾತ್ರ}other{ವೀಡಿಯೊಗಳು ಮಾತ್ರ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ಫೈಲ್ ಮಾತ್ರ}one{ಫೈಲ್ಗಳು ಮಾತ್ರ}other{ಫೈಲ್ಗಳು ಮಾತ್ರ}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ಈ ಆ್ಯಪ್ಗೆ ರೆಕಾರ್ಡ್ ಅನುಮತಿಯನ್ನು ನೀಡಲಾಗಿಲ್ಲ, ಆದರೆ ಈ USB ಸಾಧನದ ಮೂಲಕ ಆಡಿಯೊವನ್ನು ಸೆರೆಹಿಡಿಯಬಲ್ಲದು."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ವೈಯಕ್ತಿಕ"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ಕೆಲಸ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ಖಾಸಗಿ"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ವೈಯಕ್ತಿಕ ವೀಕ್ಷಣೆ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ಕೆಲಸದ ವೀಕ್ಷಣೆ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ಖಾಸಗಿ ವೀಕ್ಷಣೆ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ನಿಮ್ಮ IT ನಿರ್ವಾಹಕರಿಂದ ನಿರ್ಬಂಧಿಸಲಾಗಿದೆ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳ ಈ ವಿಷಯವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುವುದಿಲ್ಲ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳ ಈ ವಿಷಯವನ್ನು ತೆರೆಯಲಾಗುವುದಿಲ್ಲ"</string> diff --git a/java/res/values-ko/strings.xml b/java/res/values-ko/strings.xml index e9e908be..1b4f2264 100644 --- a/java/res/values-ko/strings.xml +++ b/java/res/values-ko/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{링크로 동영상 공유 중}other{링크로 동영상 #개 공유 중}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{텍스트로 파일 공유 중}other{텍스트로 파일 #개 공유 중}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{링크로 파일 공유 중}other{링크로 파일 #개 공유 중}}"</string> + <string name="sharing_album" msgid="191743129899503345">"앨범 공유"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{이미지만}other{이미지만}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{동영상만}other{동영상만}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{파일만}other{파일만}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"이 앱에는 녹음 권한이 부여되지 않았지만, 이 USB 기기를 통해 오디오를 녹음할 수 있습니다."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"개인"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"직장"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"비공개"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"개인 뷰"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"직장 뷰"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"비공개 뷰"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT 관리자에 의해 차단됨"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"이 콘텐츠는 직장 앱을 통해 공유할 수 없습니다."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"이 콘텐츠는 직장 앱으로 열 수 없습니다."</string> diff --git a/java/res/values-ky/strings.xml b/java/res/values-ky/strings.xml index 311a2169..33c58be4 100644 --- a/java/res/values-ky/strings.xml +++ b/java/res/values-ky/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Видеону шилтеме менен жөнөтүү}other{# видеону шилтеме менен жөнөтүү}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Файлды текст менен жөнөтүү}other{# файлды текст менен жөнөтүү}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Файлды шилтеме менен жөнөтүү}other{# файлды шилтеме менен жөнөтүү}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Альбом бөлүшүлүүдө"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Сүрөт гана}other{Сүрөттөр гана}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Видео гана}other{Видеолор гана}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Файл гана}other{Файлдар гана}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Бул колдонмого жаздырууга уруксат берилген эмес, бирок ушул USB түзмөгү аркылуу үндөрдү жаза алат."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Жеке"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Жумуш"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Купуя"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Жеке көрүнүш"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Жумуш көрүнүшү"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Купуя көрүнүш"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT администраторуңуз бөгөттөп койгон"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Бул нерсени жумуш колдонмолору менен бөлүшө албайсыз"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Бул нерсени жумуш колдонмолору менен ача албайсыз"</string> diff --git a/java/res/values-lo/strings.xml b/java/res/values-lo/strings.xml index 48e9a074..7a78f9a3 100644 --- a/java/res/values-lo/strings.xml +++ b/java/res/values-lo/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ກຳລັງແບ່ງປັນວິດີໂອພ້ອມລິ້ງ}other{ກຳລັງແບ່ງປັນ # ວິດີໂອພ້ອມລິ້ງ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ກຳລັງແບ່ງປັນໄຟລ໌ພ້ອມຂໍ້ຄວາມ}other{ກຳລັງແບ່ງປັນ # ໄຟລ໌ພ້ອມຂໍ້ຄວາມ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ກຳລັງແບ່ງປັນໄຟລ໌ພ້ອມລິ້ງ}other{ກຳລັງແບ່ງປັນ # ໄຟລ໌ພ້ອມລິ້ງ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ກຳລັງແບ່ງປັນອະລະບ້ຳ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ຮູບເທົ່ານັ້ນ}other{ຮູບເທົ່ານັ້ນ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ວິດີໂອເທົ່ານັ້ນ}other{ວິດີໂອເທົ່ານັ້ນ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ໄຟລ໌ເທົ່ານັ້ນ}other{ໄຟລ໌ເທົ່ານັ້ນ}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ແອັບນີ້ບໍ່ໄດ້ຮັບສິດອະນຸຍາດໃນການບັນທຶກ ແຕ່ສາມາດບັນທຶກສຽງໄດ້ຜ່ານອຸປະກອນ USB ນີ້."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ສ່ວນຕົວ"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ວຽກ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ສ່ວນຕົວ"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ມຸມມອງສ່ວນຕົວ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ມຸມມອງວຽກ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ມຸມມອງສ່ວນຕົວ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ຖືກບລັອກໄວ້ໂດຍຜູ້ເບິ່ງແຍງໄອທີຂອງທ່ານ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກແບ່ງປັນກັບແອັບບ່ອນເຮັດວຽກໄດ້"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກເປີດໄດ້ດ້ວຍແອັບບ່ອນເຮັດວຽກ"</string> diff --git a/java/res/values-lt/strings.xml b/java/res/values-lt/strings.xml index 51ffbbff..a031b1ae 100644 --- a/java/res/values-lt/strings.xml +++ b/java/res/values-lt/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Bendrinamas vaizdo įrašas su nuoroda}one{Bendrinamas # vaizdo įrašas su nuoroda}few{Bendrinami # vaizdo įrašai su nuoroda}many{Bendrinamas # vaizdo įrašo su nuoroda}other{Bendrinama # vaizdo įrašų su nuoroda}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Bendrinamas failas su tekstu}one{Bendrinamas # failas su tekstu}few{Bendrinami # failai su tekstu}many{Bendrinama # failo su tekstu}other{Bendrinama # failų su tekstu}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Bendrinamas failas su nuoroda}one{Bendrinamas # failas su nuoroda}few{Bendrinami # failai su nuoroda}many{Bendrinama # failo su nuoroda}other{Bendrinama # failų su nuoroda}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Bendrinamas albumas"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Tik vaizdas}one{Tik vaizdai}few{Tik vaizdai}many{Tik vaizdai}other{Tik vaizdai}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Tik vaizdo įrašas}one{Tik vaizdo įrašai}few{Tik vaizdo įrašai}many{Tik vaizdo įrašai}other{Tik vaizdo įrašai}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Tik failas}one{Tik failai}few{Tik failai}many{Tik failai}other{Tik failai}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Šiai programai nebuvo suteiktas leidimas įrašyti, bet ji gali užfiksuoti garsą per šį USB įrenginį."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Asmeninis"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Darbo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privatus"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Asmeninė peržiūra"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Darbo peržiūra"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatus rodinys"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Užblokavo jūsų IT administratorius"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Šio turinio negalima bendrinti su darbo programomis"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Šio turinio negalima atidaryti naudojant darbo programas"</string> diff --git a/java/res/values-lv/strings.xml b/java/res/values-lv/strings.xml index de5c352b..ead503a4 100644 --- a/java/res/values-lv/strings.xml +++ b/java/res/values-lv/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Tiek kopīgots videoklips ar saiti}zero{Tiek kopīgoti # videoklipi ar saitēm}one{Tiek kopīgots # videoklips ar saitēm}other{Tiek kopīgoti # videoklipi ar saitēm}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Tiek kopīgots fails ar tekstu}zero{Tiek kopīgoti # faili ar tekstu}one{Tiek kopīgots # fails ar tekstu}other{Tiek kopīgoti # faili ar tekstu}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Tiek kopīgots fails ar saiti}zero{Tiek kopīgoti # faili ar saitēm}one{Tiek kopīgots # fails ar saitēm}other{Tiek kopīgoti # faili ar saitēm}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Notiek albuma kopīgošana"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Tikai attēls}zero{Tikai attēli}one{Tikai attēli}other{Tikai attēli}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Tikai videoklips}zero{Tikai videoklipi}one{Tikai videoklipi}other{Tikai videoklipi}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Tikai fails}zero{Tikai faili}one{Tikai faili}other{Tikai faili}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Šai lietotnei nav piešķirta ierakstīšanas atļauja, taču tā varētu tvert audio, izmantojot šo USB ierīci."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Privātais profils"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Darba profils"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privāts"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personisks skats"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Darba skats"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privātais skats"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloķējis jūsu IT administrators"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Šo saturu nevar kopīgot ar darba lietotnēm"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Šo saturu nevar atvērt darba lietotnēs"</string> diff --git a/java/res/values-mk/strings.xml b/java/res/values-mk/strings.xml index 7ef3a9ca..b1e73dea 100644 --- a/java/res/values-mk/strings.xml +++ b/java/res/values-mk/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Се споделува видео со линк}one{Се споделуваат # видео со линк}other{Се споделуваat # видеa со линк}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Се споделува датотека со SMS}one{Се споделуваат # датотека со SMS}other{Се споделуваат # датотеки со SMS}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Се споделува датотека со линк}one{Се споделуваат # датотека со линк}other{Се споделуваат # датотеки со линк}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Споделување албум"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Само слика}one{Само слики}other{Само слики}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видео}one{Само видеа}other{Само видеа}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Само датотека}one{Само датотеки}other{Само датотеки}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"На апликацијава не ѝ е доделена дозвола за снимање, но може да снима аудио преку овој USB-уред."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Лични"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"За работа"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Приватно"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Личен приказ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Работен приказ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Приватен приказ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Блокирано од IT-администраторот"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Овие содржини не може да се споделуваат со работни апликации"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Овие содржини не може да се отвораат со работни апликации"</string> diff --git a/java/res/values-ml/strings.xml b/java/res/values-ml/strings.xml index 03b01db9..4e80ca86 100644 --- a/java/res/values-ml/strings.xml +++ b/java/res/values-ml/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ലിങ്കിനൊപ്പം വീഡിയോ പങ്കിടുന്നു}other{ലിങ്കിനൊപ്പം # വീഡിയോകൾ പങ്കിടുന്നു}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ടെക്സ്റ്റിനൊപ്പം ഫയൽ പങ്കിടുന്നു}other{ടെക്സ്റ്റിനൊപ്പം # ഫയലുകൾ പങ്കിടുന്നു}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ലിങ്കിനൊപ്പം ഫയൽ പങ്കിടുന്നു}other{ലിങ്കിനൊപ്പം # ഫയലുകൾ പങ്കിടുന്നു}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ആൽബം പങ്കിടൽ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ചിത്രം മാത്രം}other{ചിത്രങ്ങൾ മാത്രം}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{വീഡിയോ മാത്രം}other{വീഡിയോകൾ മാത്രം}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ഫയൽ മാത്രം}other{ഫയലുകൾ മാത്രം}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ഈ ആപ്പിന് റെക്കോർഡ് അനുമതി നൽകിയിട്ടില്ല, എന്നാൽ ഈ USB ഉപകരണത്തിലൂടെ ഓഡിയോ ക്യാപ്ചർ ചെയ്യാനാവും."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"വ്യക്തിപരം"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ഔദ്യോഗികം"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"സ്വകാര്യം"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"വ്യക്തിപര കാഴ്ച"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ഔദ്യോഗിക കാഴ്ച"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"സ്വകാര്യ കാഴ്ച"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"നിങ്ങളുടെ ഐടി അഡ്മിൻ ബ്ലോക്ക് ചെയ്തു"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ഔദ്യോഗിക ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം പങ്കിടാനാകില്ല"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ഔദ്യോഗിക ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string> diff --git a/java/res/values-mn/strings.xml b/java/res/values-mn/strings.xml index 339ca5e4..77ef0edc 100644 --- a/java/res/values-mn/strings.xml +++ b/java/res/values-mn/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Холбоостой видео хуваалцаж байна}other{Холбоостой # видео хуваалцаж байна}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Тексттэй файл хуваалцаж байна}other{Тексттэй # файл хуваалцаж байна}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Холбоостой файл хуваалцаж байна}other{Холбоостой # файл хуваалцаж байна}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Цомог хуваалцаж байна"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Зөвхөн зураг}other{Зөвхөн зургууд}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Зөвхөн видео}other{Зөвхөн видеонууд}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Зөвхөн файл}other{Зөвхөн файлууд}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Энэ апликейшнд бичих зөвшөөрөл олгогдоогүй ч энэ USB төхөөрөмжөөр дамжуулан аудио бичиж чадсан."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Хувийн"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Ажил"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Хувийн"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Хувийн харагдах байдал"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ажлын харагдах байдал"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Хувийн харагдах байдал"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Таны IT админ блоклосон"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Энэ контентыг ажлын аппуудаар хуваалцах боломжгүй"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Энэ контентыг ажлын аппуудаар нээх боломжгүй"</string> diff --git a/java/res/values-mr/strings.xml b/java/res/values-mr/strings.xml index 5202a3b7..93db4e04 100644 --- a/java/res/values-mr/strings.xml +++ b/java/res/values-mr/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{लिंकसह व्हिडिओ शेअर करत आहे}other{लिंकसह # व्हिडिओ शेअर करत आहे}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{मजकुरासह फाइल शेअर करत आहे}other{मजकुरासह # फाइल शेअर करत आहे}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{लिंकसह फाइल शेअर करत आहे}other{लिंकसह # फाइल शेअर करत आहे}}"</string> + <string name="sharing_album" msgid="191743129899503345">"अल्बम शेअर करत आहे"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{फक्त इमेज}other{फक्त इमेज}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{फक्त व्हिडिओ}other{फक्त व्हिडिओ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{फक्त फाइल}other{फक्त फाइल}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"या अॅपला रेकॉर्ड करण्याची परवानगी दिली गेली नाही पण हे USB डिव्हाइस वापरून ऑडिओ कॅप्चर केला जाऊ शकतो."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"वैयक्तिक"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"कार्य"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"खाजगी"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"वैयक्तिक दृश्य"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"कार्य दृश्य"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"खाजगी व्ह्यू"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"तुमच्या IT ॲडमिनने ब्लॉक केले"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"हा आशय कार्य ॲप्ससह शेअर केला जाऊ शकत नाही"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"हा आशय कार्य ॲप्स वापरून उघडला जाऊ शकत नाही"</string> diff --git a/java/res/values-ms/strings.xml b/java/res/values-ms/strings.xml index f1ac4d1d..968c4090 100644 --- a/java/res/values-ms/strings.xml +++ b/java/res/values-ms/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Berkongsi video dengan pautan}other{Berkongsi # video dengan pautan}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Berkongsi fail dengan teks}other{Berkongsi # fail dengan teks}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Berkongsi fail dengan pautan}other{Berkongsi # fail dengan pautan}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Berkongsi album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Imej sahaja}other{Imej sahaja}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video sahaja}other{Video sahaja}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fail sahaja}other{Fail sahaja}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Apl ini belum diberikan kebenaran merakam tetapi dapat merakam audio melalui peranti USB ini."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Peribadi"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Kerja"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Peribadi"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Paparan peribadi"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Paparan kerja"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Paparan peribadi"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Disekat oleh pentadbir IT anda"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Kandungan ini tidak boleh dikongsi dengan apl kerja"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Kandungan ini tidak boleh dibuka dengan apl kerja"</string> diff --git a/java/res/values-my/strings.xml b/java/res/values-my/strings.xml index c3ab1ee2..d31d8a48 100644 --- a/java/res/values-my/strings.xml +++ b/java/res/values-my/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{လင့်ခ်ပါသောဗီဒီယိုကို မျှဝေနေသည်}other{လင့်ခ်ပါသောဗီဒီယို # ခုကို မျှဝေနေသည်}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{စာသားပါသောဖိုင်ကို မျှဝေနေသည်}other{စာသားပါသောဖိုင် # ခုကို မျှဝေနေသည်}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{လင့်ခ်ပါသောဖိုင်ကို မျှဝေနေသည်}other{လင့်ခ်ပါသောဖိုင် # ခုကို မျှဝေနေသည်}}"</string> + <string name="sharing_album" msgid="191743129899503345">"အယ်လ်ဘမ် မျှဝေနေသည်"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ပုံသာလျှင်}other{ပုံများသာလျှင်}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ဗီဒီယိုသာလျှင်}other{ဗီဒီယိုများသာလျှင်}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ဖိုင်သာလျှင်}other{ဖိုင်များသာလျှင်}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ဤအက်ပ်ကို အသံဖမ်းခွင့် ပေးမထားသော်လည်း ၎င်းသည် ဤ USB စက်ပစ္စည်းမှတစ်ဆင့် အသံများကို ဖမ်းယူနိုင်ပါသည်။"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ကိုယ်ပိုင်"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"အလုပ်"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"သီးသန့်"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ပုဂ္ဂိုလ်ရေးဆိုင်ရာ မြင်ကွင်း"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"အလုပ် မြင်ကွင်း"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"သီးသန့်ပြသခြင်း"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"သင်၏ IT စီမံခန့်ခွဲသူက ပိတ်ထားသည်"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ဤအကြောင်းအရာကို အလုပ်သုံးအက်ပ်များဖြင့် မမျှဝေနိုင်ပါ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ဤအကြောင်းအရာကို အလုပ်သုံးအက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string> diff --git a/java/res/values-nb/strings.xml b/java/res/values-nb/strings.xml index a2c6da68..587233b4 100644 --- a/java/res/values-nb/strings.xml +++ b/java/res/values-nb/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deler videoen med link}other{Deler # videoer med link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deler filen med tekst}other{Deler # filer med tekst}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deler filen med link}other{Deler # filer med link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deler album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Bare bildet}other{Bare bildene}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Bare videoen}other{Bare videoene}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Bare filen}other{Bare filene}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Denne appen har ikke fått tillatelse til å spille inn, men kan ta opp lyd med denne USB-enheten."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personlig"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Jobb"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personlig visning"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Jobbvisning"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privat visning"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokkert av IT-administratoren din"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Dette innholdet kan ikke deles med jobbapper"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Dette innholdet kan ikke åpnes med jobbapper"</string> diff --git a/java/res/values-ne/strings.xml b/java/res/values-ne/strings.xml index 176067f2..a800d0b0 100644 --- a/java/res/values-ne/strings.xml +++ b/java/res/values-ne/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{लिंक भएको भिडियो सेयर गरिँदै छ}other{लिंक भएका # वटा भिडियो सेयर गरिँदै छन्}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{टेक्स्ट भएको फाइल सेयर गरिँदै छ}other{टेक्स्ट भएका # वटा फाइल सेयर गरिँदै छन्}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{लिंक भएको फाइल सेयर गरिँदै छ}other{लिंक भएका # वटा फाइल सेयर गरिँदै छन्}}"</string> + <string name="sharing_album" msgid="191743129899503345">"एल्बम सेयर गरिँदै छ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{फोटो मात्र}other{फोटोहरू मात्र}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{भिडियो मात्र}other{भिडियोहरू मात्र}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{फाइल मात्र}other{फाइलहरू मात्र}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"यो एपलाई रेकर्ड गर्ने अनुमति प्रदान गरिएको छैन तर यसले यो USB यन्त्रमार्फत अडियो क्याप्चर गर्न सक्छ।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"व्यक्तिगत"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"काम"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"निजी"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"व्यक्तिगत दृश्य"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"कार्य दृश्य"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"निजी भ्यू"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"तपाईंका IT एड्मिनले ब्लक गर्नुभएको छ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"यो सामग्री कामसम्बन्धी एपहरूमार्फत सेयर गर्न मिल्दैन"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"यो सामग्री कामसम्बन्धी एपहरूमार्फत खोल्न मिल्दैन"</string> diff --git a/java/res/values-nl/strings.xml b/java/res/values-nl/strings.xml index 7ef1513b..4f8c48b2 100644 --- a/java/res/values-nl/strings.xml +++ b/java/res/values-nl/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Video delen via link}other{# video\'s delen via link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Bestand delen via tekstbericht}other{# bestanden delen via tekstbericht}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Bestand delen via link}other{# bestanden delen via link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Album wordt gedeeld"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Alleen afbeelding}other{Alleen afbeeldingen}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Alleen video}other{Alleen video\'s}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Alleen bestand}other{Alleen bestanden}}"</string> @@ -76,14 +77,16 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Deze app heeft geen opnamerechten gekregen, maar zou audio kunnen vastleggen via dit USB-apparaat."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Persoonlijk"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Werk"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privé"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Persoonlijke weergave"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Werkweergave"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privéweergave"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Geblokkeerd door je IT-beheerder"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Deze content kan niet worden gedeeld met werk-apps"</string> <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_turn_on_work_apps" msgid="7115260573975624516">"Werk-apps zijn onderbroken"</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> diff --git a/java/res/values-or/strings.xml b/java/res/values-or/strings.xml index 93c60db2..b41e4cd2 100644 --- a/java/res/values-or/strings.xml +++ b/java/res/values-or/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ଲିଙ୍କ ସହ ଭିଡିଓ ସେୟାର କରାଯାଉଛି}other{ଲିଙ୍କ ସହ #ଟି ଭିଡିଓ ସେୟାର କରାଯାଉଛି}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ଟେକ୍ସଟ ସହ ଫାଇଲ ସେୟାର କରାଯାଉଛି}other{ଟେକ୍ସଟ ସହ #ଟି ଫାଇଲ ସେୟାର କରାଯାଉଛି}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ଲିଙ୍କ ସହ ଫାଇଲ ସେୟାର କରାଯାଉଛି}other{ଲିଙ୍କ ସହ #ଟି ଫାଇଲ ସେୟାର କରାଯାଉଛି}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ଆଲବମ ସେୟାର କରାଯାଉଛି"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{କେବଳ ଇମେଜ}other{କେବଳ ଇମେଜଗୁଡ଼ିକ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{କେବଳ ଭିଡିଓ}other{କେବଳ ଭିଡିଓଗୁଡ଼ିକ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{କେବଳ ଫାଇଲ}other{କେବଳ ଫାଇଲଗୁଡ଼ିକ}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ଏହି ଆପ୍କୁ ରେକର୍ଡ କରିବାକୁ ଅନୁମତି ଦିଆଯାଇ ନାହିଁ କିନ୍ତୁ ଏହି USB ଡିଭାଇସ୍ ଜରିଆରେ ଅଡିଓ କ୍ୟାପ୍ଚର୍ କରିପାରିବ।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ବ୍ୟକ୍ତିଗତ"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ୱାର୍କ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ପ୍ରାଇଭେଟ"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ବ୍ୟକ୍ତିଗତ ଭ୍ୟୁ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ୱାର୍କ ଭ୍ୟୁ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ପ୍ରାଇଭେଟ ଭ୍ୟୁ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ଆପଣଙ୍କ IT ଆଡମିନଙ୍କ ଦ୍ୱାରା ବ୍ଲକ୍ କରାଯାଇଛି"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ଏହି ବିଷୟବସ୍ତୁ ୱାର୍କ ଆପଗୁଡ଼ିକରେ ସେୟାର୍ କରାଯାଇପାରିବ ନାହିଁ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ଏହି ବିଷୟବସ୍ତୁ ୱାର୍କ ଆପଗୁଡ଼ିକରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string> diff --git a/java/res/values-pa/strings.xml b/java/res/values-pa/strings.xml index 872168d6..df920108 100644 --- a/java/res/values-pa/strings.xml +++ b/java/res/values-pa/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ਲਿੰਕ ਨਾਲ ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿੰਕ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿੰਕ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝੇ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਫ਼ਾਈਲਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ਲਿੰਕ ਨਾਲ ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿੰਕ ਨਾਲ # ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿੰਕ ਨਾਲ # ਫ਼ਾਈਲਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ਐਲਬਮ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ਸਿਰਫ਼ ਚਿੱਤਰ}one{ਸਿਰਫ਼ ਚਿੱਤਰ}other{ਸਿਰਫ਼ ਚਿੱਤਰ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ਸਿਰਫ਼ ਵੀਡੀਓ}one{ਸਿਰਫ਼ ਵੀਡੀਓ}other{ਸਿਰਫ਼ ਵੀਡੀਓ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ਸਿਰਫ਼ ਫ਼ਾਈਲ}one{ਸਿਰਫ਼ ਫ਼ਾਈਲ}other{ਸਿਰਫ਼ ਫ਼ਾਈਲਾਂ}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ਇਸ ਐਪ ਨੂੰ ਰਿਕਾਰਡ ਕਰਨ ਦੀ ਇਜਾਜ਼ਤ ਨਹੀਂ ਦਿੱਤੀ ਗਈ ਪਰ ਇਹ USB ਡੀਵਾਈਸ ਰਾਹੀਂ ਆਡੀਓ ਕੈਪਚਰ ਕਰ ਸਕਦੀ ਹੈ।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ਨਿੱਜੀ"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ਕੰਮ ਸੰਬੰਧੀ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ਨਿੱਜੀ"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ਵਿਅਕਤੀਗਤ ਦ੍ਰਿਸ਼"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ਕਾਰਜ ਦ੍ਰਿਸ਼"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ਨਿੱਜੀ ਦ੍ਰਿਸ਼"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ਤੁਹਾਡੇ ਆਈ.ਟੀ. ਪ੍ਰਸ਼ਾਸਕ ਵੱਲੋਂ ਬਲਾਕ ਕੀਤਾ ਗਿਆ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string> diff --git a/java/res/values-pl/strings.xml b/java/res/values-pl/strings.xml index 40fe5860..a9829275 100644 --- a/java/res/values-pl/strings.xml +++ b/java/res/values-pl/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{I jeszcze # plik}few{I jeszcze # pliki}many{I jeszcze # plików}other{I jeszcze # pliku}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Udostępnianie tekstu"</string> <string name="sharing_link" msgid="2307694372813942916">"Udostępnianie linku"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Udostępnianie obrazu}few{Udostępnianie # obrazów}many{Udostępnianie # obrazów}other{Udostępnianie # obrazu}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Udostępniam obraz}few{Udostępniam # obrazy}many{Udostępniam # obrazów}other{Udostępniam # obrazu}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Udostępnianie filmu}few{Udostępnianie # filmów}many{Udostępnianie # filmów}other{Udostępnianie # filmu}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Udostępnianie # pliku}few{Udostępnianie # plików}many{Udostępnianie # plików}other{Udostępnianie # pliku}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Udostępnianie obrazu przez SMS}few{Udostępnianie # obrazów przez SMS}many{Udostępnianie # obrazów przez SMS}other{Udostępnianie # obrazu przez SMS}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Udostępnianie filmu przez link}few{Udostępnianie # filmów przez link}many{Udostępnianie # filmów przez link}other{Udostępnianie # filmu przez link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Udostępnianie pliku przez SMS}few{Udostępnianie # plików przez SMS}many{Udostępnianie # plików przez SMS}other{Udostępnianie # pliku przez SMS}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Udostępnianie pliku przez link}few{Udostępnianie # plików przez link}many{Udostępnianie # plików przez link}other{Udostępnianie # pliku przez link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Udostępnij album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Tylko obraz}few{Tylko obrazy}many{Tylko obrazy}other{Tylko obrazy}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Tylko film}few{Tylko filmy}many{Tylko filmy}other{Tylko filmy}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Tylko plik}few{Tylko pliki}many{Tylko pliki}other{Tylko pliki}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ta aplikacja nie ma uprawnień do nagrywania, ale może rejestrować dźwięk za pomocą tego urządzenia USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osobiste"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Służbowe"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Prywatna"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Widok osobisty"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Widok służbowy"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Widok prywatny"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Działanie zablokowane przez administratora IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tych treści nie można udostępniać w aplikacjach służbowych"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tych treści nie można otworzyć w aplikacjach służbowych"</string> diff --git a/java/res/values-pt-rBR/strings.xml b/java/res/values-pt-rBR/strings.xml index ec52fd28..255fcbe6 100644 --- a/java/res/values-pt-rBR/strings.xml +++ b/java/res/values-pt-rBR/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{Mais # arquivo}one{Mais # arquivo}many{Mais # de arquivos}other{Mais # arquivos}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Compartilhando texto"</string> <string name="sharing_link" msgid="2307694372813942916">"Compartilhando link"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartilhando imagem}one{Compartilhando # imagem}many{Compartilhando # de imagens}other{Compartilhando # imagens}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartilhar imagem}one{Compartilhar # imagem}many{Compartilhar # de imagens}other{Compartilhar # imagens}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartilhando vídeo}one{Compartilhando # vídeo}many{Compartilhando # de vídeos}other{Compartilhando # vídeos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartilhando # arquivo}one{Compartilhando # arquivo}many{Compartilhando # de arquivos}other{Compartilhando # arquivos}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartilhando imagem com texto}one{Compartilhando # imagem com texto}many{Compartilhando # de imagens com texto}other{Compartilhando # imagens com texto}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartilhando vídeo com link}one{Compartilhando # vídeo com link}many{Compartilhando # de vídeos com link}other{Compartilhando # vídeos com link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartilhando arquivo com texto}one{Compartilhando # arquivo com texto}many{Compartilhando # de arquivos com texto}other{Compartilhando # arquivos com texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartilhando arquivo com link}one{Compartilhando # arquivo com link}many{Compartilhando # de arquivos com link}other{Compartilhando # arquivos com link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Compartilhando álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Somente imagem}one{Somente imagem}many{Somente imagens}other{Somente imagens}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Somente vídeo}one{Somente vídeo}many{Somente vídeos}other{Somente vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Somente arquivo}one{Somente arquivo}many{Somente arquivos}other{Somente arquivos}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Este app não tem permissão de gravação, mas pode capturar áudio pelo dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pessoal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabalho"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Particular"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualização pessoal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualização de trabalho"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualização particular"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Compartilhamento bloqueado pelo administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Não é possível compartilhar esse conteúdo com apps de trabalho"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir esse conteúdo com apps de trabalho"</string> diff --git a/java/res/values-pt-rPT/strings.xml b/java/res/values-pt-rPT/strings.xml index c60b923b..4b44cb11 100644 --- a/java/res/values-pt-rPT/strings.xml +++ b/java/res/values-pt-rPT/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{A partilhar vídeo com link}many{A partilhar # vídeos com link}other{A partilhar # vídeos com link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{A partilhar ficheiro com texto}many{A partilhar # ficheiros com texto}other{A partilhar # ficheiros com texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{A partilhar ficheiro com link}many{A partilhar # ficheiros com link}other{A partilhar # ficheiros com link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Partilhar álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Apenas imagem}many{Apenas imagens}other{Apenas imagens}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Apenas vídeo}many{Apenas vídeos}other{Apenas vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Apenas ficheiro}many{Apenas ficheiros}other{Apenas ficheiros}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Esta app não recebeu autorização de gravação, mas pode capturar áudio através deste dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pessoal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabalho"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privada"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vista pessoal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vista de trabalho"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqueado pelo administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Não é possível partilhar este conteúdo com apps de trabalho"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir este conteúdo com apps de trabalho"</string> diff --git a/java/res/values-pt/strings.xml b/java/res/values-pt/strings.xml index ec52fd28..255fcbe6 100644 --- a/java/res/values-pt/strings.xml +++ b/java/res/values-pt/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{Mais # arquivo}one{Mais # arquivo}many{Mais # de arquivos}other{Mais # arquivos}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Compartilhando texto"</string> <string name="sharing_link" msgid="2307694372813942916">"Compartilhando link"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartilhando imagem}one{Compartilhando # imagem}many{Compartilhando # de imagens}other{Compartilhando # imagens}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartilhar imagem}one{Compartilhar # imagem}many{Compartilhar # de imagens}other{Compartilhar # imagens}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartilhando vídeo}one{Compartilhando # vídeo}many{Compartilhando # de vídeos}other{Compartilhando # vídeos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartilhando # arquivo}one{Compartilhando # arquivo}many{Compartilhando # de arquivos}other{Compartilhando # arquivos}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartilhando imagem com texto}one{Compartilhando # imagem com texto}many{Compartilhando # de imagens com texto}other{Compartilhando # imagens com texto}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartilhando vídeo com link}one{Compartilhando # vídeo com link}many{Compartilhando # de vídeos com link}other{Compartilhando # vídeos com link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartilhando arquivo com texto}one{Compartilhando # arquivo com texto}many{Compartilhando # de arquivos com texto}other{Compartilhando # arquivos com texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartilhando arquivo com link}one{Compartilhando # arquivo com link}many{Compartilhando # de arquivos com link}other{Compartilhando # arquivos com link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Compartilhando álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Somente imagem}one{Somente imagem}many{Somente imagens}other{Somente imagens}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Somente vídeo}one{Somente vídeo}many{Somente vídeos}other{Somente vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Somente arquivo}one{Somente arquivo}many{Somente arquivos}other{Somente arquivos}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Este app não tem permissão de gravação, mas pode capturar áudio pelo dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pessoal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabalho"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Particular"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualização pessoal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualização de trabalho"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualização particular"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Compartilhamento bloqueado pelo administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Não é possível compartilhar esse conteúdo com apps de trabalho"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir esse conteúdo com apps de trabalho"</string> diff --git a/java/res/values-ro/strings.xml b/java/res/values-ro/strings.xml index d6cae158..1839e09a 100644 --- a/java/res/values-ro/strings.xml +++ b/java/res/values-ro/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Se trimite videoclipul cu linkul}few{Se trimit # videoclipuri cu linkul}other{Se trimit # de videoclipuri cu linkul}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Se trimite fișierul cu text}few{Se trimit # fișiere cu text}other{Se trimit # de fișiere cu text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Se trimite fișierul cu linkul}few{Se trimit # fișiere cu linkul}other{Se trimit # de fișiere cu linkul}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Se permite accesul la album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Numai imaginea}few{Numai imaginile}other{Numai imaginile}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Numai videoclipul}few{Numai videoclipurile}other{Numai videoclipurile}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Numai fișierul}few{Numai fișierele}other{Numai fișierele}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Permisiunea de înregistrare nu a fost acordată aplicației, dar aceasta poate să înregistreze conținut audio prin intermediul acestui dispozitiv USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Serviciu"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Afișarea conținutului personal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Afișarea conținutului de lucru"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Secțiunea Privat"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocat de administratorul IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Acest conținut nu poate fi trimis cu aplicații pentru lucru"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Acest conținut nu poate fi deschis cu aplicații pentru lucru"</string> diff --git a/java/res/values-ru/strings.xml b/java/res/values-ru/strings.xml index 618e0a6f..31d171ff 100644 --- a/java/res/values-ru/strings.xml +++ b/java/res/values-ru/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Отправка видео со ссылкой}one{Отправка # видео со ссылкой}few{Отправка # видео со ссылкой}many{Отправка # видео со ссылкой}other{Отправка # видео со ссылкой}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Отправка файла с текстом}one{Отправка # файла с текстом}few{Отправка # файлов с текстом}many{Отправка # файлов с текстом}other{Отправка # файла с текстом}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Отправка файла со ссылкой}one{Отправка # файла со ссылкой}few{Отправка # файлов со ссылкой}many{Отправка # файлов со ссылкой}other{Отправка # файла со ссылкой}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Вы делитесь альбомом"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Только изображение}one{Только изображения}few{Только изображения}many{Только изображения}other{Только изображения}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Только видео}one{Только видео}few{Только видео}many{Только видео}other{Только видео}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Только файл}one{Только файлы}few{Только файлы}many{Только файлы}other{Только файлы}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Приложению не разрешено записывать звук, однако оно может делать это с помощью этого USB-устройства."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Личное"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Рабочее"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Личное пространство"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Просмотр личных данных"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Просмотр рабочих данных"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Личное пространство"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Заблокировано вашим администратором"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Этим контентом нельзя делиться с рабочими приложениями."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Этот контент нельзя открыть в рабочем приложении."</string> diff --git a/java/res/values-si/strings.xml b/java/res/values-si/strings.xml index 176206e8..09418f55 100644 --- a/java/res/values-si/strings.xml +++ b/java/res/values-si/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{සබැඳිය සමග වීඩියෝව බෙදා ගැනීම}one{සබැඳිය සමග වීඩියෝ #ක් බෙදා ගැනීම}other{සබැඳිය සමග වීඩියෝ #ක් බෙදා ගැනීම}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{පෙළ සමග ගොනුව බෙදා ගැනීම}one{පෙළ සමග ගොනු #ක් බෙදා ගැනීම}other{පෙළ සමග ගොනු #ක් බෙදා ගැනීම}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{සබැඳිය සමග ගොනුව බෙදා ගැනීම}one{සබැඳිය සමග ගොනු #ක් බෙදා ගැනීම}other{සබැඳිය සමග ගොනු #ක් බෙදා ගැනීම}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ඇල්බමය බෙදා ගැනීම"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{රූපය පමණි}one{රූප පමණි}other{රූප පමණි}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{වීඩියෝව පමණි}one{වීඩියෝ පමණි}other{වීඩියෝ පමණි}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ගොනුව පමණි}one{ගොනු පමණි}other{ගොනු පමණි}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"මෙම යෙදුමට පටිගත කිරීම් අවසරයක් ලබා දී නොමැති නමුත් මෙම USB උපාංගය හරහා ශ්රව්ය ග්රහණය කර ගත හැකිය."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"පුද්ගලික"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"කාර්යාල"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"පෞද්ගලික"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"පෞද්ගලික දසුන"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"කාර්යාල දසුන"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"පෞද්ගලික දසුන"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ඔබගේ IT පරිපාලක විසින් අවහිර කර ඇත"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"මෙම අන්තර්ගතය කාර්යාල යෙදුම් සමඟ බෙදා ගත නොහැකිය"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"මෙම අන්තර්ගතය කාර්යාල යෙදුම් සමඟ විවෘත කළ නොහැකිය"</string> diff --git a/java/res/values-sk/strings.xml b/java/res/values-sk/strings.xml index 1ac43e60..ef39351f 100644 --- a/java/res/values-sk/strings.xml +++ b/java/res/values-sk/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{a # ďalší súbor}few{a # ďalšie súbory}many{+ # more files}other{a # ďalších súborov}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Zdieľa sa textová správa"</string> <string name="sharing_link" msgid="2307694372813942916">"Zdieľa sa odkaz"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Zdieľa sa obrázok}few{Zdieľajú sa # obrázky}many{Sharing # images}other{Zdieľa sa # obrázkov}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Zdieľanie obrázku}few{Zdieľanie # obrázkov}many{Sharing # images}other{Zdieľanie # obrázkov}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Zdieľa sa video}few{Zdieľajú sa # videá}many{Sharing # videos}other{Zdieľa sa # videí}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Zdieľa sa # súbor}few{Zdieľajú sa # súbory}many{Sharing # files}other{Zdieľa sa # súborov}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Zdieľa sa obrázok s textom}few{Zdieľajú sa # obrázky s textom}many{Sharing # images with text}other{Zdieľa sa # obrázkov s textom}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Zdieľa sa video s odkazom}few{Zdieľajú sa # videá s odkazom}many{Sharing # videos with link}other{Zdieľa sa # videí s odkazom}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Zdieľa sa súbor s textom}few{Zdieľajú sa # súbory s textom}many{Sharing # files with text}other{Zdieľa sa # súborov s textom}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Zdieľa sa súbor s odkazom}few{Zdieľajú sa # súbory s odkazom}many{Sharing # files with link}other{Zdieľa sa # súborov s odkazom}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Zdieľanie albumu"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Iba obrázok}few{Iba obrázky}many{Iba obrázky}other{Iba obrázky}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Iba video}few{Iba videá}many{Iba videá}other{Iba videá}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Iba súbor}few{Iba súbory}many{Iba súbory}other{Iba súbory}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Tejto aplikácii nebolo udelené povolenie na nahrávanie, ale môže nasnímať zvuk cez toto zariadenie USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osobné"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Pracovné"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Súkromné"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Osobné zobrazenie"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pracovné zobrazenie"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Súkromné zobrazenie"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokované vaším správcom IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tento obsah sa nedá zdieľať pomocou pracovných aplikácií"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tento obsah sa nedá otvoriť pomocou pracovných aplikácií"</string> diff --git a/java/res/values-sl/strings.xml b/java/res/values-sl/strings.xml index 0ef88727..559cf3d1 100644 --- a/java/res/values-sl/strings.xml +++ b/java/res/values-sl/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deljenje videoposnetka s povezavo}one{Deljenje # videoposnetka s povezavo}two{Deljenje # videoposnetkov s povezavo}few{Deljenje # videoposnetkov s povezavo}other{Deljenje # videoposnetkov s povezavo}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deljenje datoteke z besedilom}one{Deljenje # datoteke z besedilom}two{Deljenje # datotek z besedilom}few{Deljenje # datotek z besedilom}other{Deljenje # datotek z besedilom}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deljenje datoteke s povezavo}one{Deljenje # datoteke s povezavo}two{Deljenje # datotek s povezavo}few{Deljenje # datotek s povezavo}other{Deljenje # datotek s povezavo}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deljenje albuma"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}two{Samo slike}few{Samo slike}other{Samo slike}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo videoposnetek}one{Samo videoposnetki}two{Samo videoposnetki}few{Samo videoposnetki}other{Samo videoposnetki}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo datoteka}one{Samo datoteke}two{Samo datoteke}few{Samo datoteke}other{Samo datoteke}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ta aplikacija sicer nima dovoljenja za snemanje, vendar bi lahko zajemala zvok prek te naprave USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osebno"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Delo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Zasebno"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Pogled osebnega profila"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pogled delovnega profila"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Zasebni pogled"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokiral skrbnik za IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Te vsebine ni mogoče deliti z delovnimi aplikacijami."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Te vsebine ni mogoče odpreti z delovnimi aplikacijami."</string> diff --git a/java/res/values-sq/strings.xml b/java/res/values-sq/strings.xml index 95c3e57c..cd7fffee 100644 --- a/java/res/values-sq/strings.xml +++ b/java/res/values-sq/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Po ndahet një video me lidhje}other{Po ndahen # video me lidhje}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Po ndahet një skedar me tekst}other{Po ndahen # skedarë me tekst}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Po ndahet një skedar me lidhje}other{Po ndahen # skedarë me lidhje}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albumi po ndahet"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Vetëm imazhi}other{Vetëm imazhet}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vetëm videoja}other{Vetëm videot}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Vetëm skedari}other{Vetëm skedarët}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Këtij aplikacioni nuk i është dhënë leje për regjistrim, por mund të regjistrojë audio përmes kësaj pajisjeje USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Puna"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Pamja personale"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pamja e punës"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Pamja private"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bllokuar nga administratori yt i teknologjisë së informacionit"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Kjo përmbajtje nuk mund të ndahet me aplikacione pune"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Kjo përmbajtje nuk mund të hapet me aplikacione pune"</string> diff --git a/java/res/values-sr/strings.xml b/java/res/values-sr/strings.xml index 511a1293..a5d0e57b 100644 --- a/java/res/values-sr/strings.xml +++ b/java/res/values-sr/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ још # фајл}one{+ још # фајл}few{+ још # фајла}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{Дели се слика}one{Дели се # слика}few{Деле се # слике}other{Дели се # слика}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Дељење слике}one{Дељење # слике}few{Дељење # слике}other{Дељење # слика}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Дели се видео}one{Дели се # видео}few{Деле се # видео снимка}other{Дели се # видеа}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Дели се # фајл}one{Дели се # фајл}few{Деле се # фајла}other{Дели се # фајлова}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Дели се слика са текстом}one{Дели се # слика са текстом}few{Деле се # слике са текстом}other{Дели се # слика са текстом}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Дели се видео са линком}one{Дели се # видео са линком}few{Деле се # видео снимка са линком}other{Дели се # видеа са линком}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Дели се фајл са текстом}one{Дели се # фајл са текстом}few{Деле се # фајла са текстом}other{Дели се # фајлова са текстом}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Дели се фајл са линком}one{Дели се # фајл са линком}few{Деле се # фајла са линком}other{Дели се # фајлова са линком}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Дељени албум"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Само слика}one{Само слике}few{Само слике}other{Само слике}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видео}one{Само видео снимци}few{Само видео снимци}other{Само видео снимци}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Само фајл}one{Само фајлови}few{Само фајлови}other{Само фајлови}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ова апликација нема дозволу за снимање, али би могла да снима звук помоћу овог USB уређаја."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Лично"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Пословно"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Приватно"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Лични приказ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Приказ за посао"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Приватни приказ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Блокира ИТ администратор"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Овај садржај не може да се дели помоћу пословних апликација"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Овај садржај не може да се отвара помоћу пословних апликација"</string> diff --git a/java/res/values-sv/strings.xml b/java/res/values-sv/strings.xml index 7ed2d3f1..43492b9f 100644 --- a/java/res/values-sv/strings.xml +++ b/java/res/values-sv/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Delar video med länk}other{Delar # videor med länk}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Delar fil med text}other{Delar # filer med text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Delar fil med länk}other{Delar # filer med länk}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Delar album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Endast bild}other{Endast bilder}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Endast video}other{Endast videor}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Endast fil}other{Endast filer}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Appen har inte fått inspelningsbehörighet men kan spela in ljud via denna USB-enhet."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Privat"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Jobb"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personlig vy"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Jobbvy"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privat vy"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blockeras av IT-administratören"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Det här innehållet kan inte delas med jobbappar"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Det här innehållet kan inte öppnas med jobbappar"</string> diff --git a/java/res/values-sw/strings.xml b/java/res/values-sw/strings.xml index de45a78c..74405a95 100644 --- a/java/res/values-sw/strings.xml +++ b/java/res/values-sw/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Inashiriki video na kiungo}other{Inashiriki video # na kiungo}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Inashiriki faili na maandishi}other{Inashiriki faili # na maandishi}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Inashiriki faili na kiungo}other{Inashiriki faili # na kiungo}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Kutumia albamu pamoja"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Picha pekee}other{Picha pekee}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video pekee}other{Video pekee}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Faili pekee}other{Faili pekee}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Programu hii haijapewa ruhusa ya kurekodi lakini inaweza kurekodi sauti kupitia kifaa hiki cha USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Binafsi"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Kazini"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Wa faragha"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Mwonekano wa binafsi"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Mwonekano wa kazini"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Mwonekano wa faragha"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Imezuiwa na msimamizi wako wa Tehama"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Huwezi kushiriki maudhui haya na programu za kazini"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Huwezi kufungua maudhui haya ukitumia programu za kazini"</string> diff --git a/java/res/values-ta/strings.xml b/java/res/values-ta/strings.xml index c95e5cb1..bef51c34 100644 --- a/java/res/values-ta/strings.xml +++ b/java/res/values-ta/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{இணைப்புடன் வீடியோவைப் பகிர்கிறது}other{இணைப்புடன் # வீடியோக்களைப் பகிர்கிறது}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{வார்த்தைகளைக் கொண்ட ஃபைலைப் பகிர்கிறது}other{வார்த்தைகளைக் கொண்ட # ஃபைல்களைப் பகிர்கிறது}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{இணைப்பைக் கொண்ட ஃபைலைப் பகிர்கிறது}other{இணைப்பைக் கொண்ட # ஃபைல்களைப் பகிர்கிறது}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ஆல்பத்தைப் பகிர்தல்"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{படம் மட்டும்}other{படங்கள் மட்டும்}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{வீடியோ மட்டும்}other{வீடியோக்கள் மட்டும்}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ஃபைல் மட்டும்}other{ஃபைல்கள் மட்டும்}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"இந்த ஆப்ஸிற்கு ரெக்கார்டு செய்வதற்கான அனுமதி வழங்கப்படவில்லை, எனினும் இந்த USB சாதனம் மூலம் ஆடியோவைப் பதிவுசெய்ய முடியும்."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"தனிப்பட்ட சுயவிவரம்"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"பணிச் சுயவிவரம்"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ரகசியம்"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"தனிப்பட்ட காட்சி"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"பணிக் காட்சி"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ரகசியக் காட்சி"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"இதை உங்கள் IT நிர்வாகி தடைசெய்துள்ளார்"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"பணி ஆப்ஸுடன் இந்த உள்ளடக்கத்தைப் பகிர முடியாது"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"பணி ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string> diff --git a/java/res/values-te/strings.xml b/java/res/values-te/strings.xml index a8b9457a..30f45be5 100644 --- a/java/res/values-te/strings.xml +++ b/java/res/values-te/strings.xml @@ -32,7 +32,7 @@ <string name="whichEditApplicationLabel" msgid="5992662938338600364">"ఎడిట్"</string> <string name="whichSendApplication" msgid="59510564281035884">"షేర్ చేయండి"</string> <string name="whichSendApplicationNamed" msgid="495577664218765855">"<xliff:g id="APP">%1$s</xliff:g> యాప్తో షేర్ చేయండి"</string> - <string name="whichSendApplicationLabel" msgid="2391198069286568035">"షేర్ చేయి"</string> + <string name="whichSendApplicationLabel" msgid="2391198069286568035">"షేర్ చేయండి"</string> <string name="whichSendToApplication" msgid="2724450540348806267">"దీన్ని ఉపయోగించి పంపండి"</string> <string name="whichSendToApplicationNamed" msgid="1996548940365954543">"<xliff:g id="APP">%1$s</xliff:g> యాప్ను ఉపయోగించి పంపండి"</string> <string name="whichSendToApplicationLabel" msgid="6909037198280591110">"పంపండి"</string> @@ -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{ఇమేజ్ను షేర్ చేయడం}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> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{లింక్ చేయడం ద్వారా వీడియోను షేర్ చేయడం}other{లింక్ చేయడం ద్వారా # వీడియోలను షేర్ చేయడం}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా ఫైల్ను షేర్ చేయడం}other{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా # ఫైల్స్ను షేర్ చేయడం}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{లింక్ చేయడం ద్వారా ఫైల్ను షేర్ చేయడం}other{లింక్ చేయడం ద్వారా # ఫైల్స్ను షేర్ చేయడం}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ఆల్బమ్ షేర్ చేయబడుతోంది"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ఇమేజ్ మాత్రమే}other{ఇమేజ్లు మాత్రమే}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{వీడియో మాత్రమే}other{వీడియోలు మాత్రమే}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ఫైల్ మాత్రమే}other{ఫైళ్లు మాత్రమే}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ఈ యాప్కు రికార్డ్ చేసే అనుమతి మంజూరు కాలేదు, అయినా ఈ USB పరికరం ద్వారా ఆడియోను క్యాప్చర్ చేయగలదు."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"వ్యక్తిగతం"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"వర్క్ ప్లేస్"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ప్రైవేట్"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"వ్యక్తిగత వీక్షణ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"పని వీక్షణ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ప్రైవేట్ వీక్షణ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"మీ IT అడ్మిన్ ద్వారా బ్లాక్ చేయబడింది"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ఈ కంటెంట్ వర్క్ యాప్తో షేర్ చేయడం సాధ్యం కాదు"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ఈ కంటెంట్ వర్క్ యాప్తో తెరవడం సాధ్యం కాదు"</string> diff --git a/java/res/values-th/strings.xml b/java/res/values-th/strings.xml index af91064b..8db86fff 100644 --- a/java/res/values-th/strings.xml +++ b/java/res/values-th/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{กำลังแชร์วิดีโอพร้อมลิงก์}other{กำลังแชร์วิดีโอ # รายการพร้อมลิงก์}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{กำลังแชร์ไฟล์พร้อมข้อความ}other{กำลังแชร์ไฟล์ # รายการพร้อมข้อความ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{กำลังแชร์ไฟล์พร้อมลิงก์}other{กำลังแชร์ไฟล์ # รายการพร้อมลิงก์}}"</string> + <string name="sharing_album" msgid="191743129899503345">"กำลังแชร์อัลบั้ม"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{รูปภาพเท่านั้น}other{รูปภาพเท่านั้น}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{วิดีโอเท่านั้น}other{วิดีโอเท่านั้น}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ไฟล์เท่านั้น}other{ไฟล์เท่านั้น}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"แอปนี้ไม่ได้รับอนุญาตให้บันทึกเสียงแต่อาจเก็บเสียงผ่านอุปกรณ์ USB นี้ได้"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ส่วนตัว"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"งาน"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ส่วนตัว"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"มุมมองส่วนตัว"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ดูงาน"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"มุมมองส่วนตัว"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ผู้ดูแลระบบไอทีบล็อกไว้"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"แชร์เนื้อหานี้โดยใช้แอปงานไม่ได้"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"เปิดเนื้อหานี้โดยใช้แอปงานไม่ได้"</string> diff --git a/java/res/values-tl/strings.xml b/java/res/values-tl/strings.xml index cb4ff654..59d51005 100644 --- a/java/res/values-tl/strings.xml +++ b/java/res/values-tl/strings.xml @@ -32,7 +32,7 @@ <string name="whichEditApplicationLabel" msgid="5992662938338600364">"I-edit"</string> <string name="whichSendApplication" msgid="59510564281035884">"Ibahagi"</string> <string name="whichSendApplicationNamed" msgid="495577664218765855">"Ibahagi gamit ang <xliff:g id="APP">%1$s</xliff:g>"</string> - <string name="whichSendApplicationLabel" msgid="2391198069286568035">"Ibahagi"</string> + <string name="whichSendApplicationLabel" msgid="2391198069286568035">"I-share"</string> <string name="whichSendToApplication" msgid="2724450540348806267">"Ipadala gamit ang"</string> <string name="whichSendToApplicationNamed" msgid="1996548940365954543">"Ipadala gamit ang <xliff:g id="APP">%1$s</xliff:g>"</string> <string name="whichSendToApplicationLabel" msgid="6909037198280591110">"Ipadala"</string> @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{+ # pang file}one{+ # pang file}other{+ # pang file}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Ibinabahagi ang text"</string> <string name="sharing_link" msgid="2307694372813942916">"Ibinabahagi ang link"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Ibinabahagi ang larawan}one{Ibinabahagi ang # larawan}other{Ibinabahagi ang # na larawan}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Shine-share ang larawan}one{Shine-share ang # larawan}other{Shine-share ang # na larawan}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Ibinabahagi ang video}one{Ibinabahagi ang # video}other{Ibinabahagi ang # na video}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Nagshe-share ng # file}one{Nagshe-share ng # file}other{Nagshe-share ng # na file}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Nagbabahagi ng larawang may text}one{Nagbabahagi ng # larawang may text}other{Nagbabahagi ng # na larawang may text}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Nagbabahagi ng video na may link}one{Nagbabahagi ng # video na may link}other{Nagbabahagi ng # na video na may link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Nagbabahagi ng file na may text}one{Nagbabahagi ng # file na may text}other{Nagbabahagi ng # na file na may text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Nagbabahagi ng file na may link}one{Nagbabahagi ng # file na may link}other{Nagbabahagi ng # na file na may link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Ibinabahagi ang album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Larawan lang}one{Mga larawan lang}other{Mga larawan lang}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video lang}one{Mga video lang}other{Mga video lang}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File lang}one{Mga file lang}other{Mga file lang}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Hindi nabigyan ng pahintulot ang app na ito para mag-record pero nakakapag-capture ito ng audio sa pamamagitan ng USB device na ito."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabaho"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Pribado"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal na view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"View ng trabaho"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Pribadong view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Na-block ng iyong IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Hindi puwedeng ibahagi sa mga app para sa trabaho ang content na ito"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Hindi puwedeng buksan sa mga app para sa trabaho ang content na ito"</string> diff --git a/java/res/values-tr/strings.xml b/java/res/values-tr/strings.xml index 53d74bb9..eadfeeaa 100644 --- a/java/res/values-tr/strings.xml +++ b/java/res/values-tr/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Bağlantı ekli video paylaşılıyor}other{Bağlantı ekli # video paylaşılıyor}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Metin ekli dosya paylaşılıyor}other{Metin ekli # dosya paylaşılıyor}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Bağlantı ekli dosya paylaşılıyor}other{Bağlantı ekli # dosya paylaşılıyor}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albüm paylaşılıyor"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Yalnızca resim}other{Yalnızca resimler}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Yalnızca video}other{Yalnızca videolar}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Yalnızca dosya}other{Yalnızca dosyalar}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Bu uygulamaya ses kaydetme izni verilmedi ancak bu USB cihazı üzerinden sesleri yakalayabilir."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Kişisel"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"İş"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Gizli"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Kişisel görünüm"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"İş görünümü"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Gizli görünüm"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"BT yöneticiniz tarafından engellendi"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bu içerik, iş uygulamalarıyla paylaşılamaz"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu içerik, iş uygulamalarıyla açılamaz"</string> diff --git a/java/res/values-uk/strings.xml b/java/res/values-uk/strings.xml index f9d810af..a517db45 100644 --- a/java/res/values-uk/strings.xml +++ b/java/res/values-uk/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Надсилання відео з посиланням}one{Надсилання # відео з посиланням}few{Надсилання # відео з посиланням}many{Надсилання # відео з посиланням}other{Надсилання # відео з посиланням}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Надсилання файлу з текстом}one{Надсилання # файлу з текстом}few{Надсилання # файлів із текстом}many{Надсилання # файлів із текстом}other{Надсилання # файлу з текстом}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Надсилання файлу з посиланням}one{Надсилання # файлу з посиланням}few{Надсилання # файлів із посиланням}many{Надсилання # файлів із посиланням}other{Надсилання # файлу з посиланням}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Надання спільного доступу до альбома"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Лише зображення}one{Лише зображення}few{Лише зображення}many{Лише зображення}other{Лише зображення}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Лише відео}one{Лише відео}few{Лише відео}many{Лише відео}other{Лише відео}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Лише файл}one{Лише файли}few{Лише файли}many{Лише файли}other{Лише файли}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Цей додаток не має дозволу на запис, але він може фіксувати звук через цей USB-пристрій."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Особисте"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Робоче"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Приватний простір"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Особистий перегляд"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Робочий перегляд"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Приватний перегляд"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Заблоковано адміністратором"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Цим контентом не можна ділитися в робочих додатках"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Цей контент не можна відкривати в робочих додатках"</string> diff --git a/java/res/values-ur/strings.xml b/java/res/values-ur/strings.xml index 6a101d98..716a99af 100644 --- a/java/res/values-ur/strings.xml +++ b/java/res/values-ur/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{لنک کے ساتھ ویڈیو کا اشتراک کیا جا رہا ہے}other{لنک کے ساتھ # ویڈیوز کا اشتراک کیا جا رہا ہے}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ٹیکسٹ کے ساتھ فائل کا اشتراک کیا جا رہا ہے}other{ٹیکسٹ کے ساتھ # فائلز کا اشتراک کیا جا رہا ہے}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{لنک کے ساتھ فائل کا اشتراک کیا جا رہا ہے}other{لنک کے ساتھ # فائلز کا اشتراک کیا جا رہا ہے}}"</string> + <string name="sharing_album" msgid="191743129899503345">"البم کا اشتراک کیا جا رہا ہے"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{صرف تصویر}other{صرف تصاویر}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{صرف ویڈیو}other{صرف ویڈیوز}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{صرف فائل}other{صرف فائلز}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"اس ایپ کو ریکارڈ کرنے کی اجازت عطا نہیں کی گئی ہے مگر اس USB آلہ کے ذریعے آڈیو کیپچر کر سکتی ہے۔"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ذاتی"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"دفتر"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"نجی"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ذاتی ملاحظہ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"دفتری ملاحظہ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"نجی ملاحظہ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"آپ کے IT منتظم کے ذریعے مسدود کردہ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"اس مواد کا اشتراک ورک ایپس کے ساتھ نہیں کیا جا سکتا"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"اس مواد کو ورک ایپس کے ساتھ نہیں کھولا جا سکتا"</string> diff --git a/java/res/values-uz/strings.xml b/java/res/values-uz/strings.xml index 24249f50..d8e0bab7 100644 --- a/java/res/values-uz/strings.xml +++ b/java/res/values-uz/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Havolali videoni yuborish}other{# ta havolali videoni yuborish}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Matnli faylni yuborish}other{# ta matnli faylni yuborish}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Havolali faylni yuborish}other{# ta havolali faylni yuborish}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albom ulashilmoqda"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Faqat rasm}other{Faqat rasmlar}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Faqat video}other{Faqat videolar}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Faqat fayl}other{Faqat fayllar}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Bu ilovaga yozib olish ruxsati berilmagan, lekin shu USB orqali ovozlarni yozib olishi mumkin."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Shaxsiy"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Ish"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Maxfiy"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Shaxsiy rejim"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ishchi rejim"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Maxfiy rejim"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Administratoringiz tomonidan bloklangan"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bu kontent ishga oid ilovalar bilan ulashilmaydi"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu kontent ishga oid ilovalar bilan ochilmaydi"</string> diff --git a/java/res/values-vi/strings.xml b/java/res/values-vi/strings.xml index b08d9a3a..a8d70cfc 100644 --- a/java/res/values-vi/strings.xml +++ b/java/res/values-vi/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Đang chia sẻ video có đường liên kết}other{Đang chia sẻ # video có đường liên kết}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Đang chia sẻ tệp có văn bản}other{Đang chia sẻ # tệp có văn bản}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Đang chia sẻ tệp có đường liên kết}other{Đang chia sẻ # tệp có đường liên kết}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Chia sẻ album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Chỉ chia sẻ hình ảnh}other{Chỉ chia sẻ các hình ảnh}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Chỉ chia sẻ video}other{Chỉ chia sẻ các video}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Chỉ chia sẻ tệp}other{Chỉ chia sẻ các tệp}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ứng dụng này chưa được cấp quyền ghi âm nhưng vẫn có thể ghi âm thông qua thiết bị USB này."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Cá nhân"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Công việc"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Riêng tư"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Chế độ xem cá nhân"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Chế độ xem công việc"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Chế độ xem riêng tư"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bị quản trị viên CNTT chặn"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bạn không thể chia sẻ nội dung này bằng ứng dụng công việc"</string> <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> diff --git a/java/res/values-zh-rCN/strings.xml b/java/res/values-zh-rCN/strings.xml index e208e106..504bac6a 100644 --- a/java/res/values-zh-rCN/strings.xml +++ b/java/res/values-zh-rCN/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{正在分享带有链接的视频}other{正在分享带有链接的 # 个视频}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{正在分享带有文本的文件}other{正在分享带有文本的 # 个文件}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{正在分享带有链接的文件}other{正在分享带有链接的 # 个文件}}"</string> + <string name="sharing_album" msgid="191743129899503345">"分享影集"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{仅限图片}other{仅限图片}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{仅限视频}other{仅限视频}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{仅限文件}other{仅限文件}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"此应用未获得录音权限,但能通过此 USB 设备录制音频。"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"个人"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"工作"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"私密"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"个人视图"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"工作视图"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"私密视图"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"已被 IT 管理员禁止"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"无法使用工作应用分享该内容"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"无法使用工作应用打开该内容"</string> diff --git a/java/res/values-zh-rHK/strings.xml b/java/res/values-zh-rHK/strings.xml index 837b1587..c54fc4b5 100644 --- a/java/res/values-zh-rHK/strings.xml +++ b/java/res/values-zh-rHK/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{正在分享影片 (含有連結)}other{正在分享 # 部影片 (含有連結)}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{正在分享檔案 (含有文字)}other{正在分享 # 個檔案 (含有文字)}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{正在分享檔案 (含有連結)}other{正在分享 # 個檔案 (含有連結)}}"</string> + <string name="sharing_album" msgid="191743129899503345">"共享相簿"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{僅含圖片}other{僅含圖片}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{僅含影片}other{僅含影片}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{僅含檔案}other{僅含檔案}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"此應用程式尚未獲授予錄音權限,但可透過此 USB 裝置記錄音訊。"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"個人"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"工作"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"私人"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"個人檢視模式"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"工作檢視模式"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"私人檢視模式"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"已被你的 IT 管理員封鎖"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"無法使用工作應用程式分享此內容"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"無法使用工作應用程式開啟此內容"</string> diff --git a/java/res/values-zh-rTW/strings.xml b/java/res/values-zh-rTW/strings.xml index 0fddc70e..288602f4 100644 --- a/java/res/values-zh-rTW/strings.xml +++ b/java/res/values-zh-rTW/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{分享含有連結的影片}other{分享 # 部含有連結的影片}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{分享含有文字的檔案}other{分享含有文字的 # 個檔案}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{分享含有連結的檔案}other{分享含有連結的 # 個檔案}}"</string> + <string name="sharing_album" msgid="191743129899503345">"共享相簿"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{只有圖片}other{只有圖片}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{只有影片}other{只有影片}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{只有檔案}other{只有檔案}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"這個應用程式未取得錄製內容的權限,但可以透過這部 USB 裝置錄製音訊。"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"個人"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"工作"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"私人"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"個人檢視模式"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"工作檢視模式"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"私人檢視模式"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT 管理員已封鎖這項操作"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"無法透過工作應用程式分享這項內容"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"無法使用工作應用程式開啟這項內容"</string> diff --git a/java/res/values-zu/strings.xml b/java/res/values-zu/strings.xml index b651eb06..e30f51fd 100644 --- a/java/res/values-zu/strings.xml +++ b/java/res/values-zu/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Yabelana ngevidiyo ngelinki}one{Yabelana ngamavidiyo angu-# ngelinki}other{Yabelana ngamavidiyo angu-# ngelinki}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Yabelana ngefayela ngombhalo}one{Yabelana ngamafayela angu-# ngombhalo}other{Yabelana ngamafayela angu-# ngombhalo}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Yabelana ngefayela ngelinki}one{Yabelana ngamafayela angu-# ngelinki}other{Yabelana ngamafayela angu-# ngelinki}}"</string> + <string name="sharing_album" msgid="191743129899503345">"I-albhamu eyabiwe"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Isithombe kuphela}one{Izithombe kuphela}other{Izithombe kuphela}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ividiyo kuphela}one{Amavidiyo kuphela}other{Amavidiyo kuphela}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Ifayela kuphela}one{Amafayela kuphela}other{Amafayela kuphela}}"</string> @@ -76,8 +77,10 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Lolu hlelo lokusebenza alunikeziwe imvume yokurekhoda kodwa lungathwebula umsindo ngale divayisi ye-USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Okomuntu siqu"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Umsebenzi"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Okuyimfihlo"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Ukubuka komuntu siqu"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ukubuka komsebenzi"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Ukubuka okuyimfihlo"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Kuvinjelwe umlawuli wakho we-IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Lokhu okuqukethwe akukwazi ukwabiwa nama-app womsebenzi"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Lokhu okuqukethwe akukwazi ukukopishwa ngama-app womsebenzi"</string> diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index 8843c81a..9d77d296 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -19,7 +19,6 @@ <!-- chooser/resolver (sharesheet) spacing --> <dimen name="chooser_action_corner_radius">28dp</dimen> <dimen name="chooser_action_horizontal_margin">2dp</dimen> - <dimen name="chooser_action_max_width">200dp</dimen> <dimen name="chooser_width">450dp</dimen> <dimen name="chooser_corner_radius">28dp</dimen> <dimen name="chooser_corner_radius_small">14dp</dimen> diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 0c772573..5c1210b7 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -210,6 +210,11 @@ } </string> + + <!-- Title atop a sharing UI indicating that an album (typically of photos/videos) is being + shared [CHAR_LIMIT=50] --> + <string name="sharing_album">Sharing album</string> + <!-- Message indicating that the attached text has been removed from this share and only the images are being shared. [CHAR LIMIT=none] --> <string name="sharing_images_only">{count, plural, @@ -250,16 +255,20 @@ <!-- Prompt for the USB device resolver dialog with warning text for USB device dialogs. [CHAR LIMIT=200] --> <string name="usb_device_resolve_prompt_warn">This app has not been granted record permission but could capture audio through this USB device.</string> - <!-- ResolverActivity - profile tabs --> + <!-- ChooserActivity + ResolverActivity - profile tabs --> <!-- Label of a tab on a screen. A user can tap this tap to switch to the 'Personal' view (that shows their personal content) if they have a work profile on their device. [CHAR LIMIT=NONE] --> <string name="resolver_personal_tab">Personal</string> <!-- Label of a tab on a screen. A user can tap this tab to switch to the 'Work' view (that shows their work content) if they have a work profile on their device. [CHAR LIMIT=NONE] --> <string name="resolver_work_tab">Work</string> + <!-- Label of a tab on a screen. A user can tap this tab to switch to the 'Private' view (that shows their Private Space content) if they have private space configured on their device. [CHAR LIMIT=NONE] --> + <string name="resolver_private_tab">Private</string> <!-- Accessibility label for the personal tab button. [CHAR LIMIT=NONE] --> <string name="resolver_personal_tab_accessibility">Personal view</string> <!-- Accessibility label for the work tab button. [CHAR LIMIT=NONE] --> <string name="resolver_work_tab_accessibility">Work view</string> + <!-- Accessibility label for the private tab button. [CHAR LIMIT=NONE] --> + <string name="resolver_private_tab_accessibility">Private view</string> <!-- Title of a screen. This text lets the user know that their IT admin doesn't allow them to share this content across profiles. [CHAR LIMIT=NONE] --> <string name="resolver_cross_profile_blocked">Blocked by your IT admin</string> diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 9000ab3a..039fad56 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -125,7 +125,7 @@ import javax.inject.Inject; */ @AndroidEntryPoint(ResolverActivity.class) public class ChooserActivity extends Hilt_ChooserActivity implements - ResolverListAdapter.ResolverListCommunicator { + ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem { private static final String TAG = "ChooserActivity"; /** @@ -259,7 +259,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements new AppPredictorFactory( this, mChooserRequest.getSharedText(), - mChooserRequest.getTargetIntentFilter()), + mChooserRequest.getTargetIntentFilter(), + getPackageManager().getAppPredictionServicePackageName() != null), mChooserRequest.getTargetIntentFilter()); @@ -302,14 +303,24 @@ public class ChooserActivity extends Hilt_ChooserActivity implements BasePreviewViewModel previewViewModel = new ViewModelProvider(this, createPreviewViewModelFactory()) .get(BasePreviewViewModel.class); + previewViewModel.init( + mChooserRequest.getTargetIntent(), + getIntent(), + /*additionalContentUri = */ null, + /*focusedItemIdx = */ 0, + /*isPayloadTogglingEnabled = */ false); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.createOrReuseProvider(mChooserRequest.getTargetIntent()), + previewViewModel.getPreviewDataProvider(), mChooserRequest.getTargetIntent(), - previewViewModel.createOrReuseImageLoader(), + previewViewModel.getImageLoader(), createChooserActionFactory(), mEnterTransitionAnimationDelegate, - new HeadlineGeneratorImpl(this)); + new HeadlineGeneratorImpl(this), + ContentTypeHint.NONE, + mChooserRequest.getMetadataText(), + /*isPayloadTogglingEnabled =*/ false + ); updateStickyContentPreview(); if (shouldShowStickyContentPreview() @@ -564,6 +575,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /** * Update UI to reflect changes in data. */ + @Override public void handlePackagesChanged() { handlePackagesChanged(/* listAdapter */ null); } @@ -1206,13 +1218,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements showTargetDetails(longPressedTargetInfo); } } - - @Override - public void updateProfileViewButton(View newButtonFromProfileRow) { - mProfileView = newButtonFromProfileRow; - mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); - ChooserActivity.this.updateProfileViewButton(); - } }, chooserListAdapter, shouldShowContentPreview(), @@ -1252,7 +1257,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements maxTargetsPerRow, initialIntentsUserSpace, targetDataLoader, - null); + null, + mFeatureFlags); } @Override @@ -1409,7 +1415,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; int rowsToShow = gridAdapter.getSystemRowCount() - + gridAdapter.getProfileRowCount() + gridAdapter.getServiceTargetRowCount() + gridAdapter.getCallerAndRankedTargetRowCount(); @@ -1657,8 +1662,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (!shouldShowContentPreview()) { return false; } - boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())).getCount() == 0; + ResolverListAdapter adapter = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())); + boolean isEmpty = adapter == null || adapter.getCount() == 0; return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 876ad5c3..5060f4f1 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -54,6 +54,7 @@ import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.widget.BadgeTextView; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -109,6 +110,7 @@ public class ChooserListAdapter extends ResolverListAdapter { // Reserve spots for incoming direct share targets by adding placeholders private final TargetInfo mPlaceHolderTargetInfo; private final TargetDataLoader mTargetDataLoader; + private final boolean mUseBadgeTextViewForLabels; private final List<TargetInfo> mServiceTargets = new ArrayList<>(); private final List<DisplayResolveInfo> mCallerTargets = new ArrayList<>(); @@ -166,7 +168,8 @@ public class ChooserListAdapter extends ResolverListAdapter { int maxRankedTargets, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader, - @Nullable PackageChangeCallback packageChangeCallback) { + @Nullable PackageChangeCallback packageChangeCallback, + FeatureFlags featureFlags) { this( context, payloadIntents, @@ -185,7 +188,8 @@ public class ChooserListAdapter extends ResolverListAdapter { targetDataLoader, packageChangeCallback, AsyncTask.SERIAL_EXECUTOR, - context.getMainExecutor()); + context.getMainExecutor(), + featureFlags); } @VisibleForTesting @@ -207,7 +211,8 @@ public class ChooserListAdapter extends ResolverListAdapter { TargetDataLoader targetDataLoader, @Nullable PackageChangeCallback packageChangeCallback, Executor bgExecutor, - Executor mainExecutor) { + Executor mainExecutor, + FeatureFlags featureFlags) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super( @@ -231,6 +236,7 @@ public class ChooserListAdapter extends ResolverListAdapter { mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); mTargetDataLoader = targetDataLoader; mPackageChangeCallback = packageChangeCallback; + mUseBadgeTextViewForLabels = featureFlags.bespokeLabelView(); createPlaceHolders(); mEventLog = eventLog; mShortcutSelectionLogic = new ShortcutSelectionLogic( @@ -332,7 +338,12 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override View onCreateView(ViewGroup parent) { - return mInflater.inflate(R.layout.resolve_grid_item, parent, false); + return mInflater.inflate( + mUseBadgeTextViewForLabels + ? R.layout.chooser_grid_item + : R.layout.resolve_grid_item, + parent, + false); } @VisibleForTesting @@ -340,7 +351,7 @@ public class ChooserListAdapter extends ResolverListAdapter { public void onBindView(View view, TargetInfo info, int position) { final ViewHolder holder = (ViewHolder) view.getTag(); - holder.reset(); + resetViewHolder(holder); // Always remove the spacing listener, attach as needed to direct share targets below. holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); @@ -377,16 +388,18 @@ public class ChooserListAdapter extends ResolverListAdapter { contentDescription, mContext.getResources().getString(R.string.pinned)); } - holder.updateContentDescription(contentDescription); + updateContentDescription(holder, contentDescription); if (!info.hasDisplayIcon()) { loadDirectShareIcon((SelectableTargetInfo) info); } } else if (info.isDisplayResolveInfo()) { if (info.isPinned()) { - holder.updateContentDescription(String.join( - ". ", - info.getDisplayLabel(), - mContext.getResources().getString(R.string.pinned))); + updateContentDescription( + holder, + String.join( + ". ", + info.getDisplayLabel(), + mContext.getResources().getString(R.string.pinned))); } DisplayResolveInfo dri = (DisplayResolveInfo) info; if (!dri.hasDisplayIcon()) { @@ -398,22 +411,56 @@ public class ChooserListAdapter extends ResolverListAdapter { } if (info.isPlaceHolderTargetInfo()) { - holder.bindPlaceholder(); + bindPlaceholder(holder); } if (info.isMultiDisplayResolveInfo()) { // If the target is grouped show an indicator - holder.bindGroupIndicator( + bindGroupIndicator( + holder, mContext.getDrawable(R.drawable.chooser_group_background)); } else if (info.isPinned() && (getPositionTargetType(position) == TARGET_STANDARD || getPositionTargetType(position) == TARGET_SERVICE)) { // If the appShare or directShare target is pinned and in the suggested row show a // pinned indicator - holder.bindPinnedIndicator(mContext.getDrawable(R.drawable.chooser_pinned_background)); + bindPinnedIndicator(holder, mContext.getDrawable(R.drawable.chooser_pinned_background)); holder.text.addOnLayoutChangeListener(mPinTextSpacingListener); } } + private void resetViewHolder(ViewHolder holder) { + holder.reset(); + holder.itemView.setBackground(holder.defaultItemViewBackground); + + if (mUseBadgeTextViewForLabels) { + ((BadgeTextView) holder.text).setBadgeDrawable(null); + } + holder.text.setBackground(null); + holder.text.setPaddingRelative(0, 0, 0, 0); + } + + private void updateContentDescription(ViewHolder holder, String description) { + holder.itemView.setContentDescription(description); + } + + private void bindPlaceholder(ViewHolder holder) { + holder.itemView.setBackground(null); + } + + private void bindGroupIndicator(ViewHolder holder, Drawable indicator) { + if (mUseBadgeTextViewForLabels) { + ((BadgeTextView) holder.text).setBadgeDrawable(indicator); + } else { + holder.text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0); + holder.text.setBackground(indicator); + } + } + + private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) { + holder.text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0); + holder.text.setBackground(indicator); + } + private void loadDirectShareIcon(SelectableTargetInfo info) { if (mRequestedIcons.add(info)) { mTargetDataLoader.loadDirectShareIcon( @@ -744,9 +791,6 @@ public class ChooserListAdapter extends ResolverListAdapter { @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { processSortedList(sortedComponents, doPostProcessing); if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - //TODO: this method is different from super's only in that `notifyDataSetChanged` is - // called conditionally here; is it really important? notifyDataSetChanged(); } } diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 7ad809e9..6c7f8264 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -16,6 +16,8 @@ package com.android.intentresolver; +import static java.util.Objects.requireNonNullElse; + import android.content.ComponentName; import android.content.Intent; import android.content.IntentFilter; @@ -41,6 +43,8 @@ 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; import java.util.stream.Stream; @@ -101,6 +105,9 @@ public class ChooserRequestParameters { @Nullable private final IntentFilter mTargetIntentFilter; + @Nullable + private final CharSequence mMetadataText; + public ChooserRequestParameters( final Intent clientIntent, String referrerPackageName, @@ -125,8 +132,14 @@ public class ChooserRequestParameters { mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer); - mChosenComponentSender = clientIntent.getParcelableExtra( - Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER); + mChosenComponentSender = + Optional.ofNullable( + clientIntent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER, + IntentSender.class)) + .orElse(clientIntent.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER, + IntentSender.class)); + mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); @@ -147,6 +160,12 @@ public class ChooserRequestParameters { mChooserActions = getChooserActions(clientIntent); mModifyShareAction = getModifyShareAction(clientIntent); + + if (android.service.chooser.Flags.enableSharesheetMetadataExtra()) { + mMetadataText = clientIntent.getCharSequenceExtra(Intent.EXTRA_METADATA_TEXT); + } else { + mMetadataText = null; + } } public Intent getTargetIntent() { @@ -252,6 +271,11 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } + @Nullable + public CharSequence getMetadataText() { + return mMetadataText; + } + private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java index f0fcd149..30e69c18 100644 --- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java @@ -63,7 +63,7 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF @Override public void onClick(DialogInterface dialog, int which) { mMultiDisplayResolveInfo.setSelected(which); - ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true); + ((StartsSelectedItem) getActivity()).startSelected(mParentWhich, false, true); dismiss(); } diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index b6b7de96..ae80fad4 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -205,7 +205,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment } else { pinComponent(mTargetInfos.get(which).getResolvedComponentName()); } - ((ChooserActivity) getActivity()).handlePackagesChanged(); + ((PackagesChangedListener) getActivity()).handlePackagesChanged(); dismiss(); } diff --git a/java/src/com/android/intentresolver/ContentTypeHint.kt b/java/src/com/android/intentresolver/ContentTypeHint.kt new file mode 100644 index 00000000..f607e4ae --- /dev/null +++ b/java/src/com/android/intentresolver/ContentTypeHint.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.Intent + +/** Enum reflecting the value of [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT]. */ +enum class ContentTypeHint { + NONE, + ALBUM, +} diff --git a/java/src/com/android/intentresolver/PackagesChangedListener.kt b/java/src/com/android/intentresolver/PackagesChangedListener.kt new file mode 100644 index 00000000..10f0bf51 --- /dev/null +++ b/java/src/com/android/intentresolver/PackagesChangedListener.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 + +/** A component which can be notified when packages have changed. */ +interface PackagesChangedListener { + /** Report that packages have changed. */ + fun handlePackagesChanged() +} diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 564d8d19..80d07d2c 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -25,7 +25,6 @@ import android.content.pm.ResolveInfo; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.Drawable; -import android.net.Uri; import android.os.AsyncTask; import android.os.RemoteException; import android.os.Trace; @@ -477,9 +476,6 @@ public class ResolverListAdapter extends BaseAdapter { @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { processSortedList(sortedComponents, doPostProcessing); notifyDataSetChanged(); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - } } protected void processSortedList( @@ -651,6 +647,7 @@ public class ResolverListAdapter extends BaseAdapter { return null; } + @Override public int getCount() { int totalSize = mDisplayList == null || mDisplayList.isEmpty() ? mPlaceholderCount : mDisplayList.size(); @@ -664,6 +661,7 @@ public class ResolverListAdapter extends BaseAdapter { return mDisplayList.size(); } + @Override @Nullable public TargetInfo getItem(int position) { if (mFilterLastUsed && mLastChosenPosition >= 0 && position >= mLastChosenPosition) { @@ -676,6 +674,7 @@ public class ResolverListAdapter extends BaseAdapter { } } + @Override public long getItemId(int position) { return position; } @@ -693,6 +692,7 @@ public class ResolverListAdapter extends BaseAdapter { return mDisplayList.get(index); } + @Override public final View getView(int position, View convertView, ViewGroup parent) { View view = convertView; if (view == null) { @@ -753,9 +753,7 @@ public class ResolverListAdapter extends BaseAdapter { } private void onIconLoaded(DisplayResolveInfo displayResolveInfo, Drawable drawable) { - if (getOtherProfile() == displayResolveInfo) { - mResolverListCommunicator.updateProfileViewButton(); - } else if (!displayResolveInfo.hasDisplayIcon()) { + if (!displayResolveInfo.hasDisplayIcon()) { displayResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); notifyDataSetChanged(); } @@ -903,14 +901,18 @@ public class ResolverListAdapter extends BaseAdapter { Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent); + // ResolverListCommunicator + default void updateProfileViewButton() { + } + void onPostListReady(ResolverListAdapter listAdapter, boolean updateUi, boolean rebuildCompleted); void sendVoiceChoicesIfNeeded(); - void updateProfileViewButton(); - - boolean useLayoutWithDefault(); + default boolean useLayoutWithDefault() { + return false; + } boolean shouldGetActivityMetadata(); @@ -918,7 +920,9 @@ public class ResolverListAdapter extends BaseAdapter { * @return true to filter only apps that can handle * {@link android.content.Intent#CATEGORY_DEFAULT} intents */ - default boolean shouldGetOnlyDefaultActivities() { return true; }; + default boolean shouldGetOnlyDefaultActivities() { + return true; + } void onHandlePackagesChanged(ResolverListAdapter listAdapter); } @@ -930,7 +934,7 @@ public class ResolverListAdapter extends BaseAdapter { @VisibleForTesting public static class ViewHolder { public View itemView; - public Drawable defaultItemViewBackground; + public final Drawable defaultItemViewBackground; public TextView text; public TextView text2; @@ -940,8 +944,6 @@ public class ResolverListAdapter extends BaseAdapter { text.setText(""); text.setMaxLines(2); text.setMaxWidth(Integer.MAX_VALUE); - text.setBackground(null); - text.setPaddingRelative(0, 0, 0, 0); text2.setVisibility(View.GONE); text2.setText(""); @@ -982,10 +984,6 @@ public class ResolverListAdapter extends BaseAdapter { itemView.setContentDescription(null); } - public void updateContentDescription(String description) { - itemView.setContentDescription(description); - } - /** * Bind view holder to a TargetInfo. */ @@ -998,19 +996,5 @@ public class ResolverListAdapter extends BaseAdapter { icon.setColorFilter(null); } } - - public void bindPlaceholder() { - itemView.setBackground(null); - } - - public void bindGroupIndicator(Drawable indicator) { - text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0); - text.setBackground(indicator); - } - - public void bindPinnedIndicator(Drawable indicator) { - text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0); - text.setBackground(indicator); - } } } diff --git a/java/src/com/android/intentresolver/StartsSelectedItem.kt b/java/src/com/android/intentresolver/StartsSelectedItem.kt new file mode 100644 index 00000000..01cdf124 --- /dev/null +++ b/java/src/com/android/intentresolver/StartsSelectedItem.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 + +interface StartsSelectedItem { + /** Start the selected item. */ + fun startSelected(which: Int, always: Boolean, filtered: Boolean) +} diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index b97e6b45..4fe28384 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -17,9 +17,11 @@ package com.android.intentresolver.chooser; import android.app.Activity; +import android.content.ComponentName; import android.content.Intent; import android.os.Bundle; import android.os.UserHandle; +import android.util.Log; import androidx.annotation.Nullable; @@ -121,6 +123,19 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { } @Override + public ComponentName getResolvedComponentName() { + if (hasSelected()) { + return mTargetInfos.get(mSelected).getResolvedComponentName(); + } + // It is not expected to have this method be called on an unselected multi-display item. + // Call super to preserve the legacy (most likely erroneous) behavior. + Log.wtf( + "ChooserActivity", + "retrieving ResolvedComponentName from an unselected MultiDisplayResolveInfo"); + return super.getResolvedComponentName(); + } + + @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return mTargetInfos.get(mSelected).startAsUser(activity, options, user); } diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 10ee5af1..21c909ea 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -17,16 +17,22 @@ package com.android.intentresolver.contentpreview import android.content.Intent +import android.net.Uri import androidx.annotation.MainThread import androidx.lifecycle.ViewModel -import com.android.intentresolver.ChooserRequestParameters /** A contract for the preview view model. Added for testing. */ abstract class BasePreviewViewModel : ViewModel() { - @MainThread - abstract fun createOrReuseProvider( - targetIntent: Intent - ): PreviewDataProvider + @get:MainThread abstract val previewDataProvider: PreviewDataProvider + @get:MainThread abstract val imageLoader: ImageLoader + abstract val payloadToggleInteractor: PayloadToggleInteractor? - @MainThread abstract fun createOrReuseImageLoader(): ImageLoader + @MainThread + abstract fun init( + targetIntent: Intent, + chooserIntent: Intent, + additionalContentUri: Uri?, + focusedItemIdx: Int, + isPayloadTogglingEnabled: Boolean, + ) } diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index a015147d..6f201ad5 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; import android.content.ClipData; @@ -32,6 +33,7 @@ import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import com.android.intentresolver.ContentTypeHint; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; @@ -48,6 +50,7 @@ import kotlinx.coroutines.CoroutineScope; public final class ChooserContentPreviewUi { private final CoroutineScope mScope; + private final boolean mIsPayloadTogglingEnabled; /** * Delegate to build the default system action buttons to display in the preview layout, if/when @@ -98,8 +101,13 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint, + @Nullable CharSequence metadata, + // TODO: replace with the FeatureFlag ref when v1 is gone + boolean isPayloadTogglingEnabled) { mScope = scope; + mIsPayloadTogglingEnabled = isPayloadTogglingEnabled; mContentPreviewUi = createContentPreview( previewData, targetIntent, @@ -107,7 +115,10 @@ public final class ChooserContentPreviewUi { imageLoader, actionFactory, transitionElementStatusCallback, - headlineGenerator); + headlineGenerator, + contentTypeHint, + metadata + ); if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { transitionElementStatusCallback.onAllTransitionElementsReady(); } @@ -120,8 +131,10 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - HeadlineGenerator headlineGenerator) { - + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint, + @Nullable CharSequence metadata + ) { int previewType = previewData.getPreviewType(); if (previewType == CONTENT_PREVIEW_TEXT) { return createTextPreview( @@ -129,20 +142,31 @@ public final class ChooserContentPreviewUi { targetIntent, actionFactory, imageLoader, - headlineGenerator); + headlineGenerator, + contentTypeHint, + metadata + ); } if (previewType == CONTENT_PREVIEW_FILE) { FileContentPreviewUi fileContentPreviewUi = new FileContentPreviewUi( previewData.getUriCount(), actionFactory, - headlineGenerator); + headlineGenerator, + metadata + ); if (previewData.getUriCount() > 0) { previewData.getFirstFileName(mScope, fileContentPreviewUi::setFirstFileName); } return fileContentPreviewUi; } + + if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && mIsPayloadTogglingEnabled) { + transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO + return new ShareouselContentPreviewUi(actionFactory); + } + boolean isSingleImageShare = previewData.getUriCount() == 1 - && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); + && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); if (!TextUtils.isEmpty(text)) { FilesPlusTextContentPreviewUi previewUi = @@ -155,7 +179,9 @@ public final class ChooserContentPreviewUi { actionFactory, imageLoader, typeClassifier, - headlineGenerator); + headlineGenerator, + metadata + ); if (previewData.getUriCount() > 0) { JavaFlowHelper.collectToList( mScope, @@ -175,7 +201,9 @@ public final class ChooserContentPreviewUi { transitionElementStatusCallback, previewData.getImagePreviewFileInfoFlow(), previewData.getUriCount(), - headlineGenerator); + headlineGenerator, + metadata + ); } public int getPreferredContentPreview() { @@ -200,7 +228,10 @@ public final class ChooserContentPreviewUi { Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint, + @Nullable CharSequence metadata + ) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); CharSequence previewTitle = targetIntent.getCharSequenceExtra(Intent.EXTRA_TITLE); ClipData previewData = targetIntent.getClipData(); @@ -211,13 +242,16 @@ public final class ChooserContentPreviewUi { previewThumbnail = previewDataItem.getUri(); } } + return new TextContentPreviewUi( scope, sharingText, previewTitle, + metadata, previewThumbnail, actionFactory, imageLoader, - headlineGenerator); + headlineGenerator, + contentTypeHint); } } diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java index ad1c6c01..79bb9d3c 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java @@ -25,11 +25,13 @@ import java.lang.annotation.Retention; @Retention(SOURCE) @IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE, ContentPreviewType.CONTENT_PREVIEW_IMAGE, - ContentPreviewType.CONTENT_PREVIEW_TEXT}) + ContentPreviewType.CONTENT_PREVIEW_TEXT, + ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION}) public @interface ContentPreviewType { // Starting at 1 since 0 is considered "undefined" for some of the database transformations // of tron logs. int CONTENT_PREVIEW_IMAGE = 1; int CONTENT_PREVIEW_FILE = 2; int CONTENT_PREVIEW_TEXT = 3; + int CONTENT_PREVIEW_PAYLOAD_SELECTION = 4; } diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index dce146b0..b0fb278e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -30,12 +30,14 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; -abstract class ContentPreviewUi { +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +public abstract class ContentPreviewUi { private static final int IMAGE_FADE_IN_MILLIS = 150; static final String TAG = "ChooserPreview"; @@ -83,6 +85,19 @@ abstract class ContentPreviewUi { } } + protected static void displayMetadata(View layout, @Nullable CharSequence metadata) { + TextView metadataView = layout == null ? null : layout.findViewById(R.id.metadata); + if (metadataView == null) { + return; + } + if (!TextUtils.isEmpty(metadata)) { + metadataView.setText(metadata); + metadataView.setVisibility(View.VISIBLE); + } else { + metadataView.setVisibility(View.GONE); + } + } + protected static void displayModifyShareAction( View layout, ChooserContentPreviewUi.ActionFactory actionFactory) { ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction(); diff --git a/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt new file mode 100644 index 00000000..6a12f56c --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/CursorUriReader.kt @@ -0,0 +1,147 @@ +/* + * 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 89e7e528..d4eea8b9 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -43,15 +43,20 @@ class FileContentPreviewUi extends ContentPreviewUi { private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final HeadlineGenerator mHeadlineGenerator; @Nullable + private final CharSequence mMetadata; + @Nullable private ViewGroup mContentPreview = null; FileContentPreviewUi( int fileCount, ChooserContentPreviewUi.ActionFactory actionFactory, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + @Nullable CharSequence metadata + ) { mFileCount = fileCount; mActionFactory = actionFactory; mHeadlineGenerator = headlineGenerator; + mMetadata = metadata; } @Override @@ -91,6 +96,7 @@ class FileContentPreviewUi extends ContentPreviewUi { inflateHeadline(headlineViewParent); displayHeadline(headlineViewParent, mHeadlineGenerator.getFilesHeadline(mFileCount)); + displayMetadata(headlineViewParent, mMetadata); if (mFileCount == 0) { mContentPreview.setVisibility(View.GONE); diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 78fc6586..6832c5c4 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -57,6 +57,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; private final HeadlineGenerator mHeadlineGenerator; + @Nullable + private final CharSequence mMetadata; private final boolean mIsSingleImage; private final int mFileCount; private ViewGroup mContentPreviewView; @@ -78,7 +80,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + @Nullable CharSequence metadata) { if (isSingleImage && fileCount != 1) { throw new IllegalArgumentException( "fileCount = " + fileCount + " and isSingleImage = true"); @@ -92,6 +95,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { mImageLoader = imageLoader; mTypeClassifier = typeClassifier; mHeadlineGenerator = headlineGenerator; + mMetadata = metadata; } @Override @@ -204,6 +208,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } displayHeadline(headlineView, headline); + displayMetadata(headlineView, mMetadata); } private void prepareTextPreview( diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt index 5f87c924..21308341 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt @@ -17,12 +17,14 @@ package com.android.intentresolver.contentpreview /** - * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief - * description of the content being shared. + * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description + * of the content being shared. */ interface HeadlineGenerator { fun getTextHeadline(text: CharSequence): String + fun getAlbumHeadline(): String + fun getImagesWithTextHeadline(text: CharSequence, count: Int): String fun getVideosWithTextHeadline(text: CharSequence, count: Int): String diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index ef1e55d8..6e126822 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -34,6 +34,10 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { ) } + override fun getAlbumHeadline(): String { + return context.getString(R.string.sharing_album) + } + override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String { return getPluralString( getTemplateResource( diff --git a/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt b/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt new file mode 100644 index 00000000..1cc1a6a6 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/MutableActionFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.service.chooser.ChooserAction +import com.android.intentresolver.widget.ActionRow +import kotlinx.coroutines.flow.Flow + +interface MutableActionFactory { + /** A flow of custom actions */ + val customActionsFlow: Flow<List<ActionRow.Action>> + + /** Update custom actions */ + fun updateCustomActions(actions: List<ChooserAction>) +} diff --git a/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt new file mode 100644 index 00000000..eda5c4ca --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/PayloadToggleInteractor.kt @@ -0,0 +1,382 @@ +/* + * 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/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 38918d79..96bb8258 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -18,7 +18,6 @@ package com.android.intentresolver.contentpreview import android.content.ContentInterface import android.content.Intent -import android.database.Cursor import android.media.MediaMetadata import android.net.Uri import android.provider.DocumentsContract @@ -31,6 +30,7 @@ import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE +import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT import com.android.intentresolver.measurements.runTracing import com.android.intentresolver.util.ownedByCurrentUser @@ -74,7 +74,11 @@ open class PreviewDataProvider constructor( private val scope: CoroutineScope, private val targetIntent: Intent, + private val additionalContentUri: Uri?, private val contentResolver: ContentInterface, + // TODO: replace with the ChooserServiceFlags ref when PreviewViewModel dependencies are sorted + // out + private val isPayloadTogglingEnabled: Boolean, private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier, ) { @@ -100,6 +104,9 @@ constructor( open val uriCount: Int get() = records.size + val uris: List<Uri> + get() = records.map { it.uri } + /** * Returns a [Flow] of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] and * [FileInfo.previewUri] set (a data projection tailored for the image preview UI). @@ -122,6 +129,9 @@ constructor( * IMAGE, FILE, TEXT. */ if (!targetIntent.isSend || records.isEmpty()) { CONTENT_PREVIEW_TEXT + } else if (isPayloadTogglingEnabled && shouldShowPayloadSelection()) { + // TODO: replace with the proper flags injection + CONTENT_PREVIEW_PAYLOAD_SELECTION } else { try { runBlocking(scope.coroutineContext) { @@ -140,6 +150,22 @@ constructor( } } + private fun shouldShowPayloadSelection(): Boolean { + val extraContentUri = additionalContentUri ?: return false + return runCatching { + val authority = extraContentUri.authority + records.firstOrNull { authority == it.uri.authority } == null + } + .onFailure { + Log.w( + ContentPreviewUi.TAG, + "Failed to check URI authorities; no payload toggling", + it + ) + } + .getOrDefault(false) + } + /** * The first shared URI's metadata. This call wait's for the data to be loaded and falls back to * a crude value if the data is not loaded within a time limit. @@ -250,8 +276,7 @@ constructor( val isImageType: Boolean get() = typeClassifier.isImageType(mimeType) val supportsImageType: Boolean by lazy { - contentResolver.getStreamTypesSafe(uri)?.firstOrNull(typeClassifier::isImageType) != - null + contentResolver.getStreamTypesSafe(uri).firstOrNull(typeClassifier::isImageType) != null } val supportsThumbnail: Boolean get() = query.supportsThumbnail @@ -263,7 +288,8 @@ constructor( private val query by lazy { readQueryResult() } private fun readQueryResult(): QueryResult = - contentResolver.querySafe(uri)?.use { cursor -> + // TODO: rewrite using methods from UiMetadataHelpers.kt + contentResolver.querySafe(uri, METADATA_COLUMNS)?.use { cursor -> if (!cursor.moveToFirst()) return@use null var flagColIdx = -1 @@ -344,51 +370,3 @@ private fun getFileName(uri: Uri): String { fileName.substring(index + 1) } } - -private fun ContentInterface.getTypeSafe(uri: Uri): String? = - runTracing("getType") { - try { - getType(uri) - } catch (e: SecurityException) { - logProviderPermissionWarning(uri, "mime type") - null - } catch (t: Throwable) { - Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) - null - } - } - -private fun ContentInterface.getStreamTypesSafe(uri: Uri): Array<String>? = - runTracing("getStreamTypes") { - try { - getStreamTypes(uri, "*/*") - } catch (e: SecurityException) { - logProviderPermissionWarning(uri, "stream types") - null - } catch (t: Throwable) { - Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t) - null - } - } - -private fun ContentInterface.querySafe(uri: Uri): Cursor? = - runTracing("query") { - try { - query(uri, METADATA_COLUMNS, null, null) - } catch (e: SecurityException) { - logProviderPermissionWarning(uri, "metadata") - null - } catch (t: Throwable) { - Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) - null - } - } - -private fun logProviderPermissionWarning(uri: Uri, dataName: String) { - // The ContentResolver already logs the exception. Log something more informative. - Log.w( - ContentPreviewUi.TAG, - "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" + - " ensure that the sharesheet is given permission." - ) -} diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 6350756e..d694c6ff 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -17,58 +17,108 @@ package com.android.intentresolver.contentpreview import android.app.Application +import android.content.ContentResolver import android.content.Intent +import android.net.Uri import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras -import com.android.intentresolver.ChooserRequestParameters import com.android.intentresolver.R import com.android.intentresolver.inject.Background -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject +import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.plus -/** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */ -@HiltViewModel -class PreviewViewModel -@Inject -constructor( - private val application: Application, +/** A view model for the preview logic */ +class PreviewViewModel( + private val contentResolver: ContentResolver, + // TODO: inject ImageLoader instead + private val thumbnailSize: Int, @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : BasePreviewViewModel() { - private var previewDataProvider: PreviewDataProvider? = null - private var imageLoader: ImagePreviewImageLoader? = null + private var targetIntent: Intent? = null + private var chooserIntent: Intent? = null + private var additionalContentUri: Uri? = null + private var focusedItemIdx: Int = 0 + private var isPayloadTogglingEnabled = false - @MainThread - override fun createOrReuseProvider( - targetIntent: Intent - ): PreviewDataProvider = - previewDataProvider - ?: PreviewDataProvider( - viewModelScope + dispatcher, - targetIntent, - application.contentResolver - ) - .also { previewDataProvider = it } + override val previewDataProvider by lazy { + val targetIntent = requireNotNull(this.targetIntent) { "Not initialized" } + PreviewDataProvider( + viewModelScope + dispatcher, + targetIntent, + additionalContentUri, + contentResolver, + isPayloadTogglingEnabled, + ) + } + + override val imageLoader by lazy { + ImagePreviewImageLoader( + viewModelScope + dispatcher, + thumbnailSize, + contentResolver, + cacheSize = 16 + ) + } + + 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 createOrReuseImageLoader(): ImageLoader = - imageLoader - ?: ImagePreviewImageLoader( - viewModelScope + dispatcher, - thumbnailSize = - application.resources.getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen - ), - application.contentResolver, - cacheSize = 16 + 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 ) - .also { imageLoader = it } + }, + UriMetadataReader(contentResolver, DefaultMimeTypeClassifier), + TargetIntentModifier(targetIntent, getUri = { uri }, getMimeType = { mimeType }), + SelectionChangeCallback(contentProviderUri, chooserIntent, contentResolver) + ) + } companion object { val Factory: ViewModelProvider.Factory = @@ -77,7 +127,16 @@ constructor( override fun <T : ViewModel> create( modelClass: Class<T>, extras: CreationExtras - ): T = PreviewViewModel(checkNotNull(extras[APPLICATION_KEY])) as T + ): T { + val application: Application = checkNotNull(extras[APPLICATION_KEY]) + return PreviewViewModel( + application.contentResolver, + application.resources.getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen + ) + ) + as T + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt new file mode 100644 index 00000000..6b33e1cd --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/SelectionChangeCallback.kt @@ -0,0 +1,97 @@ +/* + * 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 new file mode 100644 index 00000000..c9431731 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/SelectionTracker.kt @@ -0,0 +1,175 @@ +/* + * 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 new file mode 100644 index 00000000..82c09986 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -0,0 +1,131 @@ +/* + * 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.res.Resources +import android.view.LayoutInflater +import android.view.View +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 + +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +class ShareouselContentPreviewUi( + private val actionFactory: ActionFactory, +) : ContentPreviewUi() { + + override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE + + override fun display( + resources: Resources, + layoutInflater: LayoutInflater, + parent: ViewGroup, + headlineViewParent: View?, + ): ViewGroup { + return displayInternal(parent, headlineViewParent).also { layout -> + displayModifyShareAction(headlineViewParent ?: layout, actionFactory) + } + } + + private fun displayInternal( + parent: ViewGroup, + headlineViewParent: View?, + ): ViewGroup { + if (headlineViewParent != null) { + inflateHeadline(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 + } + } + } + } + } + } + + 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) + ) + ) + } + } + } + return composeView + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt new file mode 100644 index 00000000..58da5bc4 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/TargetIntentModifier.kt @@ -0,0 +1,75 @@ +/* + * 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.ClipData +import android.content.ClipDescription.compareMimeTypes +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_STREAM +import android.net.Uri + +/** Modifies target intent based on current payload selection. */ +class TargetIntentModifier<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 + return Intent(originalTargetIntent).apply { + this.action = action + this.type = targetMimeType + if (action == ACTION_SEND) { + putExtra(EXTRA_STREAM, uris[0]) + } else { + putParcelableArrayListExtra(EXTRA_STREAM, uris) + } + if (uris.isNotEmpty()) { + clipData = + ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { + for (i in 1 until uris.size) { + it.addItem(ClipData.Item(uris[i])) + } + } + } + } + } + + private fun updateMimeType(itemMimeType: String?, unitedMimeType: String?): String { + itemMimeType ?: return "*/*" + unitedMimeType ?: return itemMimeType + if (compareMimeTypes(itemMimeType, unitedMimeType)) return unitedMimeType + val slashIdx = unitedMimeType.indexOf('/') + if (slashIdx >= 0 && unitedMimeType.regionMatches(0, itemMimeType, 0, slashIdx + 1)) { + return buildString { + append(unitedMimeType.substring(0, slashIdx + 1)) + append('*') + } + } + return "*/*" + } + + override fun invoke(selection: List<Item>): Intent = onSelectionChanged(selection) +} diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index b0dc3c58..fbdc5853 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -30,6 +30,7 @@ import android.widget.TextView; import androidx.annotation.Nullable; +import com.android.intentresolver.ContentTypeHint; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; @@ -42,26 +43,33 @@ class TextContentPreviewUi extends ContentPreviewUi { @Nullable private final CharSequence mPreviewTitle; @Nullable + private final CharSequence mMetadata; + @Nullable private final Uri mPreviewThumbnail; private final ImageLoader mImageLoader; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final HeadlineGenerator mHeadlineGenerator; + private final ContentTypeHint mContentTypeHint; TextContentPreviewUi( CoroutineScope scope, @Nullable CharSequence sharingText, @Nullable CharSequence previewTitle, + @Nullable CharSequence metadata, @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint) { mScope = scope; mSharingText = sharingText; mPreviewTitle = previewTitle; + mMetadata = metadata; mPreviewThumbnail = previewThumbnail; mImageLoader = imageLoader; mActionFactory = actionFactory; mHeadlineGenerator = headlineGenerator; + mContentTypeHint = contentTypeHint; } @Override @@ -139,7 +147,11 @@ class TextContentPreviewUi extends ContentPreviewUi { copyButton.setVisibility(View.GONE); } - displayHeadline(headlineViewParent, mHeadlineGenerator.getTextHeadline(mSharingText)); + String headlineText = (mContentTypeHint == ContentTypeHint.ALBUM) + ? mHeadlineGenerator.getAlbumHeadline() + : mHeadlineGenerator.getTextHeadline(mSharingText); + displayHeadline(headlineViewParent, headlineText); + displayMetadata(headlineViewParent, mMetadata); return contentPreviewLayout; } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 8ddd5273..0974c79b 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -46,6 +46,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final HeadlineGenerator mHeadlineGenerator; + @Nullable + private final CharSequence mMetadata; private final Flow<FileInfo> mFileInfoFlow; private final int mItemCount; @Nullable @@ -65,7 +67,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { TransitionElementStatusCallback transitionElementStatusCallback, Flow<FileInfo> fileInfoFlow, int itemCount, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + @Nullable CharSequence metadata) { mShowEditAction = isSingleImage; mIntentMimeType = intentMimeType; mActionFactory = actionFactory; @@ -75,6 +78,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { mFileInfoFlow = fileInfoFlow; mItemCount = itemCount; mHeadlineGenerator = headlineGenerator; + mMetadata = metadata; JavaFlowHelper.collectToList(scope, fileInfoFlow, this::setFiles); } @@ -181,5 +185,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } else { displayHeadline(layout, mHeadlineGenerator.getFilesHeadline(count)); } + displayMetadata(layout, mMetadata); } } diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt new file mode 100644 index 00000000..41638b1f --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt @@ -0,0 +1,116 @@ +/* + * 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.database.Cursor +import android.media.MediaMetadata +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL +import android.provider.Downloads +import android.provider.OpenableColumns +import android.text.TextUtils +import android.util.Log +import com.android.intentresolver.measurements.runTracing + +internal fun ContentInterface.getTypeSafe(uri: Uri): String? = + runTracing("getType") { + try { + getType(uri) + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "mime type") + null + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) + null + } + } + +internal fun ContentInterface.getStreamTypesSafe(uri: Uri): Array<String?> = + runTracing("getStreamTypes") { + try { + getStreamTypes(uri, "*/*") ?: emptyArray() + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "stream types") + emptyArray<String?>() + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t) + emptyArray<String?>() + } + } + +internal fun ContentInterface.querySafe(uri: Uri, columns: Array<String>): Cursor? = + runTracing("query") { + try { + query(uri, columns, null, null) + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "metadata") + null + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) + null + } + } + +internal fun Cursor.readSupportsThumbnail(): Boolean = + runCatching { + val flagColIdx = columnNames.indexOf(DocumentsContract.Document.COLUMN_FLAGS) + flagColIdx >= 0 && ((getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) + } + .getOrDefault(false) + +internal fun Cursor.readPreviewUri(): Uri? = + runCatching { + columnNames + .indexOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI) + .takeIf { it >= 0 } + ?.let { getString(it)?.let(Uri::parse) } + } + .getOrNull() + +internal fun Cursor.readTitle(): String = + runCatching { + var nameColIndex = -1 + var titleColIndex = -1 + // TODO: double-check why Cursor#getColumnInded didn't work + columnNames.forEachIndexed { i, columnName -> + when (columnName) { + OpenableColumns.DISPLAY_NAME -> nameColIndex = i + Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + } + } + + var title = "" + if (nameColIndex >= 0) { + title = getString(nameColIndex) ?: "" + } + if (TextUtils.isEmpty(title) && titleColIndex >= 0) { + title = getString(titleColIndex) ?: "" + } + title + } + .getOrDefault("") + +private fun logProviderPermissionWarning(uri: Uri, dataName: String) { + // The ContentResolver already logs the exception. Log something more informative. + Log.w( + ContentPreviewUi.TAG, + "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" + + " ensure that the sharesheet is given permission." + ) +} diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt new file mode 100644 index 00000000..45515e25 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt @@ -0,0 +1,66 @@ +/* + * 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.media.MediaMetadata +import android.net.Uri +import android.provider.DocumentsContract + +class UriMetadataReader( + private val contentResolver: ContentInterface, + private val typeClassifier: MimeTypeClassifier, +) : (Uri) -> FileInfo { + fun getMetadata(uri: Uri): FileInfo { + val builder = FileInfo.Builder(uri) + val mimeType = contentResolver.getTypeSafe(uri) + builder.withMimeType(mimeType) + if ( + typeClassifier.isImageType(mimeType) || + contentResolver.supportsImageType(uri) || + contentResolver.supportsThumbnail(uri) + ) { + builder.withPreviewUri(uri) + return builder.build() + } + val previewUri = contentResolver.readPreviewUri(uri) + if (previewUri != null) { + builder.withPreviewUri(previewUri) + } + return builder.build() + } + + override fun invoke(uri: Uri): FileInfo = getMetadata(uri) + + private fun ContentInterface.supportsImageType(uri: Uri): Boolean = + getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null + + private fun ContentInterface.supportsThumbnail(uri: Uri): Boolean = + querySafe(uri, arrayOf(DocumentsContract.Document.COLUMN_FLAGS))?.use { cursor -> + cursor.moveToFirst() && cursor.readSupportsThumbnail() + } + ?: false + + private fun ContentInterface.readPreviewUri(uri: Uri): Uri? = + querySafe(uri, arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI))?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.readPreviewUri() + } else { + null + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.kt new file mode 100644 index 00000000..87fb7618 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ComposeIconComposable.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. + */ +package com.android.intentresolver.contentpreview.shareousel.ui.composable + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import com.android.intentresolver.icon.AdaptiveIcon +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.icon.ComposeIcon +import com.android.intentresolver.icon.ResourceIcon + +@Composable +fun Image(icon: ComposeIcon) { + when (icon) { + is AdaptiveIcon -> Image(icon.wrapped) + is BitmapIcon -> Image(icon.bitmap.asImageBitmap(), contentDescription = null) + is ResourceIcon -> { + val localContext = LocalContext.current + val wrappedContext: Context = + object : ContextWrapper(localContext) { + override fun getResources(): Resources = icon.res + } + CompositionLocalProvider(LocalContext provides wrappedContext) { + Image(painterResource(icon.resId), contentDescription = null) + } + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt new file mode 100644 index 00000000..dc96e3c1 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselCardComposable.kt @@ -0,0 +1,98 @@ +/* + * 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.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.android.intentresolver.R + +@Composable +fun ShareouselCard( + image: @Composable () -> Unit, + selected: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier) { + image() + val topButtonPadding = 12.dp + Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { + SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) + AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + } + } +} + +@Composable +private fun AnimationIcon(modifier: Modifier = Modifier) { + Icon( + painterResource(id = R.drawable.ic_play_circle_filled_24px), + "animating", + tint = Color.White, + modifier = Modifier.size(20.dp).then(modifier) + ) +} + +@Composable +private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { + if (selected) { + val bgColor = MaterialTheme.colorScheme.primary + Icon( + painter = painterResource(id = R.drawable.checkbox), + tint = Color.White, + contentDescription = "selected", + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000) + ) + .size(20.dp) + .drawBehind { + drawCircle(color = bgColor, radius = (this.size.width / 2f) - 1f) + } + .then(modifier) + ) + } else { + Box( + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000), + ) + .border(width = 2.dp, color = Color(0xFFFFFFFF), shape = CircleShape) + .clip(CircleShape) + .size(20.dp) + .background(color = Color(0x7DC4C4C4)) + .then(modifier) + ) + } +} 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 new file mode 100644 index 00000000..5cf35297 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/composable/ShareouselComposable.kt @@ -0,0 +1,145 @@ +/* + * 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 new file mode 100644 index 00000000..18ee2539 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/shareousel/ui/viewmodel/ShareouselViewModel.kt @@ -0,0 +1,90 @@ +/* + * 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/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java index 2653c560..5f10cf32 100644 --- a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -29,9 +29,9 @@ 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.internal.R; import java.util.List; diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 51d4e677..036b686b 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -85,19 +85,11 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. * long-pressed. */ void onTargetLongPressed(int itemIndex); - - /** - * Notify the client that the provided {@code View} should be configured as the new - * "profile view" button. Callers should attach their own click listeners to implement - * behaviors on this view. - */ - void updateProfileViewButton(View newButtonFromProfileRow); } private static final int VIEW_TYPE_DIRECT_SHARE = 0; private static final int VIEW_TYPE_NORMAL = 1; private static final int VIEW_TYPE_CONTENT_PREVIEW = 2; - private static final int VIEW_TYPE_PROFILE = 3; private static final int VIEW_TYPE_AZ_LABEL = 4; private static final int VIEW_TYPE_CALLER_AND_RANK = 5; private static final int VIEW_TYPE_FOOTER = 6; @@ -170,7 +162,14 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. } public void setFooterHeight(int height) { - mFooterHeight = height; + if (mFooterHeight != height) { + mFooterHeight = height; + if (mFeatureFlags.fixTargetListFooter()) { + // we always have at least one view, the footer, see getItemCount() and + // getFooterRowCount() + notifyItemChanged(getItemCount() - 1); + } + } } /** @@ -201,7 +200,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. public int getRowCount() { return (int) ( getSystemRowCount() - + getProfileRowCount() + getServiceTargetRowCount() + getCallerAndRankedTargetRowCount() + getAzLabelRowCount() @@ -234,13 +232,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. return 1; } - public int getProfileRowCount() { - if (mChooserActivityDelegate.shouldShowTabs()) { - return 0; - } - return mChooserListAdapter.getOtherProfile() == null ? 0 : 1; - } - public int getFooterRowCount() { return 1; } @@ -272,7 +263,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. } return getSystemRowCount() - + getProfileRowCount() + getServiceTargetRowCount() + getCallerAndRankedTargetRowCount(); } @@ -280,7 +270,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. @Override public int getItemCount() { return getSystemRowCount() - + getProfileRowCount() + getServiceTargetRowCount() + getCallerAndRankedTargetRowCount() + getAzLabelRowCount() @@ -298,12 +287,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. viewType, null, null); - case VIEW_TYPE_PROFILE: - return new ItemViewHolder( - createProfileView(parent), - viewType, - null, - null); case VIEW_TYPE_AZ_LABEL: return new ItemViewHolder( createAzLabelView(parent), @@ -379,9 +362,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. int countSum = (count = getSystemRowCount()); if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; - countSum += (count = getProfileRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE; - countSum += (count = getServiceTargetRowCount()); if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; @@ -400,12 +380,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. return mChooserListAdapter.getPositionTargetType(getListPosition(position)); } - private View createProfileView(ViewGroup parent) { - View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false); - mChooserActivityDelegate.updateProfileViewButton(profileRow); - return profileRow; - } - private View createAzLabelView(ViewGroup parent) { return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false); } @@ -583,7 +557,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. } int getListPosition(int position) { - position -= getSystemRowCount() + getProfileRowCount(); + position -= getSystemRowCount(); final int serviceCount = mChooserListAdapter.getServiceTargetCount(); final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow); diff --git a/java/src/com/android/intentresolver/icon/ComposeIcon.kt b/java/src/com/android/intentresolver/icon/ComposeIcon.kt new file mode 100644 index 00000000..dbea1e55 --- /dev/null +++ b/java/src/com/android/intentresolver/icon/ComposeIcon.kt @@ -0,0 +1,88 @@ +/* + * 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.icon + +import android.content.ContentResolver +import android.content.pm.PackageManager +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.Icon +import java.io.File +import java.io.FileInputStream + +sealed interface ComposeIcon + +data class BitmapIcon(val bitmap: Bitmap) : ComposeIcon + +data class ResourceIcon(val resId: Int, val res: Resources) : ComposeIcon + +@JvmInline value class AdaptiveIcon(val wrapped: ComposeIcon) : ComposeIcon + +fun Icon.toComposeIcon(pm: PackageManager, resolver: ContentResolver): ComposeIcon? { + return when (type) { + Icon.TYPE_BITMAP -> BitmapIcon(bitmap) + Icon.TYPE_RESOURCE -> pm.resourcesForPackage(resPackage)?.let { ResourceIcon(resId, it) } + Icon.TYPE_DATA -> + BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength)) + Icon.TYPE_URI -> uriIcon(resolver) + Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap)) + Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) } + else -> error("unexpected icon type: $type") + } +} + +fun Icon.toComposeIcon(resources: Resources?, resolver: ContentResolver): ComposeIcon? { + return when (type) { + Icon.TYPE_BITMAP -> BitmapIcon(bitmap) + Icon.TYPE_RESOURCE -> resources?.let { ResourceIcon(resId, resources) } + Icon.TYPE_DATA -> + BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength)) + Icon.TYPE_URI -> uriIcon(resolver) + Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap)) + Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) } + else -> error("unexpected icon type: $type") + } +} + +// TODO: this is probably constant and doesn't need to be re-queried for each icon +fun PackageManager.resourcesForPackage(pkgName: String): Resources? { + return if (pkgName == "android") { + Resources.getSystem() + } else { + runCatching { + this@resourcesForPackage.getApplicationInfo( + pkgName, + PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_SHARED_LIBRARY_FILES + ) + } + .getOrNull() + ?.let { ai -> getResourcesForApplication(ai) } + } +} + +private fun Icon.uriIcon(resolver: ContentResolver): BitmapIcon? { + return runCatching { + when (uri.scheme) { + ContentResolver.SCHEME_CONTENT, + ContentResolver.SCHEME_FILE -> resolver.openInputStream(uri) + else -> FileInputStream(File(uriString)) + } + } + .getOrNull() + ?.let { inStream -> BitmapIcon(BitmapFactory.decodeStream(inStream)) } +} diff --git a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt index 05cf2104..0f9a18c1 100644 --- a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt +++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt @@ -1,15 +1,25 @@ package com.android.intentresolver.inject -import com.android.intentresolver.FeatureFlags -import com.android.intentresolver.FeatureFlagsImpl +import android.service.chooser.FeatureFlagsImpl as ChooserServiceFlagsImpl +import com.android.intentresolver.FeatureFlagsImpl as IntentResolverFlagsImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +typealias IntentResolverFlags = com.android.intentresolver.FeatureFlags + +typealias FakeIntentResolverFlags = com.android.intentresolver.FakeFeatureFlagsImpl + +typealias ChooserServiceFlags = android.service.chooser.FeatureFlags + +typealias FakeChooserServiceFlags = android.service.chooser.FakeFeatureFlagsImpl + @Module @InstallIn(SingletonComponent::class) object FeatureFlagsModule { - @Provides fun featureFlags(): FeatureFlags = FeatureFlagsImpl() + @Provides fun intentResolverFlags(): IntentResolverFlags = IntentResolverFlagsImpl() + + @Provides fun chooserServiceFlags(): ChooserServiceFlags = ChooserServiceFlagsImpl() } diff --git a/java/src/com/android/intentresolver/inject/FrameworkModule.kt b/java/src/com/android/intentresolver/inject/FrameworkModule.kt deleted file mode 100644 index 2f6cc6a0..00000000 --- a/java/src/com/android/intentresolver/inject/FrameworkModule.kt +++ /dev/null @@ -1,76 +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.inject - -import android.app.ActivityManager -import android.app.admin.DevicePolicyManager -import android.content.ClipboardManager -import android.content.Context -import android.content.pm.LauncherApps -import android.content.pm.ShortcutManager -import android.os.UserManager -import android.view.WindowManager -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent - -private fun <T> Context.requireSystemService(serviceClass: Class<T>): T { - return checkNotNull(getSystemService(serviceClass)) -} - -@Module -@InstallIn(SingletonComponent::class) -object FrameworkModule { - - @Provides - fun contentResolver(@ApplicationContext ctx: Context) = - requireNotNull(ctx.contentResolver) { "ContentResolver is expected but missing" } - - @Provides - fun activityManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(ActivityManager::class.java) - - @Provides - fun clipboardManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(ClipboardManager::class.java) - - @Provides - fun devicePolicyManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(DevicePolicyManager::class.java) - - @Provides - fun launcherApps(@ApplicationContext ctx: Context) = - ctx.requireSystemService(LauncherApps::class.java) - - @Provides - fun packageManager(@ApplicationContext ctx: Context) = - requireNotNull(ctx.packageManager) { "PackageManager is expected but missing" } - - @Provides - fun shortcutManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(ShortcutManager::class.java) - - @Provides - fun userManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(UserManager::class.java) - - @Provides - fun windowManager(@ApplicationContext ctx: Context) = - ctx.requireSystemService(WindowManager::class.java) -} diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt new file mode 100644 index 00000000..32894d43 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/SystemServices.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.inject + +import android.app.ActivityManager +import android.app.admin.DevicePolicyManager +import android.content.ClipboardManager +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 dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +inline fun <reified T> Context.requireSystemService(): T { + return checkNotNull(getSystemService()) +} + +@Module +@InstallIn(SingletonComponent::class) +class ActivityManagerModule { + @Provides + fun activityManager(@ApplicationContext ctx: Context): ActivityManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class ClipboardManagerModule { + @Provides + fun clipboardManager(@ApplicationContext ctx: Context): ClipboardManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class ContentResolverModule { + @Provides + fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver) +} + +@Module +@InstallIn(SingletonComponent::class) +class DevicePolicyManagerModule { + @Provides + fun devicePolicyManager(@ApplicationContext ctx: Context): DevicePolicyManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class LauncherAppsModule { + @Provides + fun launcherApps(@ApplicationContext ctx: Context): LauncherApps = ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class PackageManagerModule { + @Provides + fun packageManager(@ApplicationContext ctx: Context) = requireNotNull(ctx.packageManager) +} + +@Module +@InstallIn(SingletonComponent::class) +class ShortcutManagerModule { + @Provides + fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class UserManagerModule { + @Provides + fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class WindowManagerModule { + @Provides + fun windowManager(@ApplicationContext ctx: Context): WindowManager = ctx.requireSystemService() +} diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index 0651d26c..c6de3260 100644 --- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -107,16 +107,20 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp .setClassName(target.name.getClassName()) .build()); } - mAppPredictor.sortTargets( - appTargets, - Executors.newSingleThreadExecutor(), - new ScopedAppTargetListCallback( - mContext, - sortedAppTargets -> { - onAppTargetsSorted(targets, sortedAppTargets); - return kotlin.Unit.INSTANCE; - }).toConsumer() - ); + try { + mAppPredictor.sortTargets( + appTargets, + Executors.newSingleThreadExecutor(), + new ScopedAppTargetListCallback( + mContext, + sortedAppTargets -> { + onAppTargetsSorted(targets, sortedAppTargets); + return kotlin.Unit.INSTANCE; + }).toConsumer() + ); + } catch (IllegalStateException e) { + Log.w(TAG, "Couldn't sort targets with AppPredictionService", e); + } } private void onAppTargetsSorted( @@ -292,8 +296,12 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp new AppTarget.Builder(targetId, targetComponent.getPackageName(), mUser) .setClassName(targetComponent.getClassName()) .build(); - mAppPredictor.notifyAppTargetEvent( - new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build()); + try { + mAppPredictor.notifyAppTargetEvent( + new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build()); + } catch (IllegalStateException e) { + Log.w(TAG, "Couldn't send feedback to AppPredictionService", e); + } } } } diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt index 82f40b91..e544e064 100644 --- a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt +++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt @@ -41,16 +41,14 @@ private const val SHARED_TEXT_KEY = "shared_text" class AppPredictorFactory( private val context: Context, private val sharedText: String?, - private val targetIntentFilter: IntentFilter? + private val targetIntentFilter: IntentFilter?, + private val appPredictionAvailable: Boolean, ) { - private val mIsComponentAvailable = - context.packageManager.appPredictionServicePackageName != null - /** * Creates an AppPredictor instance for a profile or `null` if app predictor is not available. */ fun create(userHandle: UserHandle): AppPredictor? { - if (!mIsComponentAvailable) return null + if (!appPredictionAvailable) return null val contextAsUser = context.createContextAsUser(userHandle, 0 /* flags */) val extras = Bundle().apply { putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter) diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index a8b59fb0..08230d90 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -186,6 +186,11 @@ constructor( // Default to just querying ShortcutManager if AppPredictor not present. if (targetIntentFilter == null) { Log.d(TAG, "skip querying ShortcutManager for $userHandle") + sendShareShortcutInfoList( + emptyList(), + isFromAppPredictor = false, + appPredictorTargets = null + ) return } Log.d(TAG, "query ShortcutManager for user $userHandle") diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index c81bed09..62ace0da 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -1,18 +1,27 @@ +/* + * 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.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.content.Intent 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.R import com.android.intentresolver.WorkProfileAvailabilityManager -import com.android.intentresolver.icons.TargetDataLoader /** * Logic for IntentResolver Activities. Anything that is not the same across activities (including @@ -20,38 +29,7 @@ import com.android.intentresolver.icons.TargetDataLoader * activity, including test activities, but all implementations should delegate to a * CommonActivityLogic implementation. */ -interface ActivityLogic : CommonActivityLogic { - /** The intent for the target. This will always come before additional targets, if any. */ - val targetIntent: Intent - /** Whether the intent is for home. */ - val resolvingHome: Boolean - /** Custom title to display. */ - val title: CharSequence? - /** Resource ID for the title to display when there is no custom title. */ - val defaultTitleResId: Int - /** Intents received to be processed. */ - val initialIntents: List<Intent>? - /** Whether or not this activity supports choosing a default handler for the intent. */ - val supportsAlwaysUseOption: Boolean - /** Fetches display info for processed candidates. */ - val targetDataLoader: TargetDataLoader - /** The theme to use. */ - val themeResId: Int - /** - * Message showing that intent is forwarded from managed profile to owner or other way around. - */ - val profileSwitchMessage: String? - /** The intents for potential actual targets. [targetIntent] must be first. */ - val payloadIntents: List<Intent> - - /** - * Called after Activity superclass creation, but before any other onCreate logic is performed. - */ - fun preInitialization() - - /** Sets [profileSwitchMessage] to null */ - fun clearProfileSwitchMessage() -} +interface ActivityLogic : CommonActivityLogic /** * Logic that is common to all IntentResolver activities. Anything that is the same across @@ -60,21 +38,15 @@ interface ActivityLogic : CommonActivityLogic { 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 - /** The name of the referring package. */ - val referrerPackageName: String? - /** User manager system service. */ - val userManager: UserManager - /** Device policy manager system service. */ - val devicePolicyManager: DevicePolicyManager + /** Current [UserHandle]s retrievable by type. */ val annotatedUserHandles: AnnotatedUserHandles? + /** Monitors for changes to work profile availability. */ val workProfileAvailabilityManager: WorkProfileAvailabilityManager - - /** Returns display message indicating intent forwarding or null if not intent forwarding. */ - fun forwardMessageFor(intent: Intent): String? } /** @@ -84,73 +56,24 @@ interface CommonActivityLogic { */ class CommonActivityLogicImpl( override val tag: String, - activityProvider: () -> ComponentActivity, + override val activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, ) : CommonActivityLogic { - override val activity: ComponentActivity by lazy { activityProvider() } - - override val referrerPackageName: String? by lazy { - activity.referrer.let { - if (ANDROID_APP_URI_SCHEME == it?.scheme) { - it.host - } else { - null - } - } - } + private val userManager: UserManager = activity.getSystemService()!! - override val userManager: UserManager by lazy { activity.getSystemService()!! } - - override val devicePolicyManager: DevicePolicyManager by lazy { activity.getSystemService()!! } - - override val annotatedUserHandles: AnnotatedUserHandles? by lazy { + 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 by lazy { + override val workProfileAvailabilityManager = WorkProfileAvailabilityManager( userManager, annotatedUserHandles?.workProfileUserHandle, onWorkProfileStatusUpdated, ) - } - - private val forwardToPersonalMessage: String? by lazy { - devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { - activity.getString(R.string.forward_intent_to_owner) - } - } - - private val forwardToWorkMessage: String? by lazy { - devicePolicyManager.resources.getString(FORWARD_INTENT_TO_WORK) { - activity.getString(R.string.forward_intent_to_work) - } - } - - override fun forwardMessageFor(intent: Intent): String? { - val contentUserHint = intent.contentUserHint - if ( - contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId() - ) { - val originUserInfo = userManager.getUserInfo(contentUserHint) - val originIsManaged = originUserInfo?.isManagedProfile ?: false - val targetIsManaged = userManager.isManagedProfile - return when { - originIsManaged && !targetIsManaged -> forwardToPersonalMessage - !originIsManaged && targetIsManaged -> forwardToWorkMessage - else -> null - } - } - return null - } - - companion object { - private const val ANDROID_APP_URI_SCHEME = "android-app" - } } diff --git a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java index db840387..9077a18d 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/v2/ChooserActionFactory.java @@ -40,6 +40,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.v2.ui.ShareResultSender; +import com.android.intentresolver.v2.ui.model.ShareAction; import com.android.intentresolver.widget.ActionRow; import com.android.internal.annotations.VisibleForTesting; @@ -97,12 +99,12 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Context mContext; - @Nullable - private final Runnable mCopyButtonRunnable; - private final Runnable mEditButtonRunnable; + @Nullable private Runnable mCopyButtonRunnable; + private Runnable mEditButtonRunnable; private final ImmutableList<ChooserAction> mCustomActions; - private final @Nullable ChooserAction mModifyShareAction; + @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; @@ -122,17 +124,19 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio Intent targetIntent, String referrerPackageName, List<ChooserAction> chooserActions, - ChooserAction modifyShareAction, + @Nullable ChooserAction modifyShareAction, 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, + clipboardManager, targetIntent, referrerPackageName, finishCallback, @@ -149,7 +153,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio modifyShareAction, onUpdateSharedTextIsExcluded, log, + shareResultSender, finishCallback); + } @VisibleForTesting @@ -161,6 +167,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable ChooserAction modifyShareAction, Consumer<Boolean> onUpdateSharedTextIsExcluded, EventLog log, + @Nullable ShareResultSender shareResultSender, Consumer</* @Nullable */ Integer> finishCallback) { mContext = context; mCopyButtonRunnable = copyButtonRunnable; @@ -169,7 +176,21 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio 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 @@ -191,13 +212,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio for (int i = 0; i < mCustomActions.size(); i++) { final int position = i; ActionRow.Action actionRow = createCustomAction( - mContext, - mCustomActions.get(i), - mFinishCallback, - () -> { - mLog.logCustomActionSelected(position); - } - ); + mCustomActions.get(i), () -> logCustomAction(position)); if (actionRow != null) { actions.add(actionRow); } @@ -211,13 +226,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Override @Nullable public ActionRow.Action getModifyShareAction() { - return createCustomAction( - mContext, - mModifyShareAction, - mFinishCallback, - () -> { - mLog.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); - }); + return createCustomAction(mModifyShareAction, this::logModifyShareAction); } /** @@ -236,7 +245,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable private static Runnable makeCopyButtonRunnable( - Context context, + ClipboardManager clipboardManager, Intent targetIntent, String referrerPackageName, Consumer<Integer> finishCallback, @@ -252,8 +261,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); @@ -353,15 +360,11 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } @Nullable - private static ActionRow.Action createCustomAction( - Context context, - ChooserAction action, - Consumer<Integer> finishCallback, - Runnable loggingRunnable) { - if (action == null || action.getAction() == null) { + ActionRow.Action createCustomAction(@Nullable ChooserAction action, Runnable loggingRunnable) { + if (action == null) { return null; } - Drawable icon = action.getIcon().loadDrawable(context); + Drawable icon = action.getIcon().loadDrawable(mContext); if (icon == null && TextUtils.isEmpty(action.getLabel())) { return null; } @@ -378,7 +381,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio null, null, ActivityOptions.makeCustomAnimation( - context, + mContext, R.anim.slide_in_right, R.anim.slide_out_left) .toBundle()); @@ -388,8 +391,19 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio if (loggingRunnable != null) { loggingRunnable.run(); } - finishCallback.accept(Activity.RESULT_OK); + 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 index 70812642..7b5ff541 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/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,27 +16,39 @@ 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.Activity; 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; @@ -45,32 +57,48 @@ 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.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.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; @@ -79,23 +107,28 @@ import com.android.intentresolver.AnnotatedUserHandles; import com.android.intentresolver.ChooserGridLayoutManager; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRefinementManager; -import com.android.intentresolver.ChooserRequestParameters; 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; @@ -107,28 +140,50 @@ 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.text.Collator; 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; @@ -142,9 +197,9 @@ import javax.inject.Inject; * */ @SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@AndroidEntryPoint(ResolverActivity.class) +@AndroidEntryPoint(FragmentActivity.class) public class ChooserActivity extends Hilt_ChooserActivity implements - ResolverListAdapter.ResolverListCommunicator { + ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem { private static final String TAG = "ChooserActivity"; /** @@ -167,6 +222,41 @@ 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"; + 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`. @@ -184,11 +274,21 @@ public class ChooserActivity extends Hilt_ChooserActivity implements 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; @@ -216,14 +316,12 @@ 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 View mContentView = null; - private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>(); + private final Map<Integer, ProfileRecord> mProfileRecords = new HashMap<>(); private boolean mExcludeSharedText = false; /** @@ -236,35 +334,260 @@ public class ChooserActivity extends Hilt_ChooserActivity implements 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 void onCreate(Bundle savedInstanceState) { - Tracer.INSTANCE.markLaunched(); super.onCreate(savedInstanceState); - setLogic(new ChooserActivityLogic( - TAG, - () -> this, - this::onWorkProfileStatusUpdated, - () -> mTargetDataLoader, - this::onPreinitialization)); - addInitializer(this::init); - } + Log.i(TAG, "onCreate"); + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); + mActivityModel = mViewModel.getActivityModel(); - private void init() { - if (getChooserRequest() == null) { + 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 void onDestroy() { + super.onDestroy(); + if (isFinishing()) { - // Performing a clean exit: - // Skip initializing any additional resources. - return; + mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); } - setTheme(mLogic.getThemeResId()); - getEventLog().logSharesheetTriggered(); + mBackgroundThreadPoolExecutor.shutdownNow(); - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + 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(); @@ -276,26 +599,56 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // 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); + 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); - ChooserRequestParameters chooserRequest = requireChooserRequest(); + 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.createOrReuseProvider(chooserRequest.getTargetIntent()), + previewViewModel.getPreviewDataProvider(), chooserRequest.getTargetIntent(), - previewViewModel.createOrReuseImageLoader(), - createChooserActionFactory(), + previewViewModel.getImageLoader(), + actionFactory, mEnterTransitionAnimationDelegate, - new HeadlineGeneratorImpl(this)); - + new HeadlineGeneratorImpl(this), + chooserRequest.getContentTypeHint(), + chooserRequest.getMetadataText(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); updateStickyContentPreview(); if (shouldShowStickyContentPreview() || mChooserMultiProfilePagerAdapter @@ -303,12 +656,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements 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); @@ -318,64 +669,605 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getEventLog().logSharesheetExpansionChanged(isCollapsed); }); } - if (DEBUG) { Log.d(TAG, "System Time Cost is " + systemCost); } - getEventLog().logShareStarted( - mLogic.getReferrerPackageName(), + chooserRequest.getReferrerPackage(), chooserRequest.getTargetType(), chooserRequest.getCallerChooserTargets().size(), - (chooserRequest.getInitialIntents() == null) - ? 0 : chooserRequest.getInitialIntents().length, + chooserRequest.getInitialIntents().size(), isWorkProfile(), mChooserContentPreviewUi.getPreferredContentPreview(), chooserRequest.getTargetAction(), chooserRequest.getChooserActions().size(), chooserRequest.getModifyShareAction() != null ); - mEnterTransitionAnimationDelegate.postponeTransition(); } - protected final Unit onPreinitialization() { - mIntentReceivedTime.set(System.currentTimeMillis()); - mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + 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)); + } + } - mPinnedSharedPrefs = getPinnedSharedPrefs(this); - mMaxTargetsPerRow = - getResources().getInteger(R.integer.config_chooser_max_targets_per_row); - mShouldDisplayLandscape = - shouldDisplayLandscape(getResources().getConfiguration().orientation); + mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + ////////////////////////////////////////////////////////////////////////////////////////////// + // Inherited methods + ////////////////////////////////////////////////////////////////////////////////////////////// - ChooserRequestParameters chooserRequest = getChooserRequest(); - if (chooserRequest == null) { - return Unit.INSTANCE; + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + + private boolean maybeAutolaunchIfSingleTarget() { + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + if (count != 1) { + return false; } - setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); - createProfileRecords( - new AppPredictorFactory( - this, - chooserRequest.getSharedText(), - chooserRequest.getTargetIntentFilter() - ), - chooserRequest.getTargetIntentFilter() + 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 ); - return Unit.INSTANCE; } - @Nullable - private ChooserRequestParameters getChooserRequest() { - return ((ChooserActivityLogic) mLogic).getChooserRequestParameters(); + 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); } - private ChooserRequestParameters requireChooserRequest() { - return requireNonNull(getChooserRequest()); + @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(); + }; } + ////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + private AnnotatedUserHandles requireAnnotatedUserHandles() { return requireNonNull(mLogic.getAnnotatedUserHandles()); } @@ -412,7 +1304,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 @@ -435,25 +1327,22 @@ 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()) { + boolean filterLastUsed) { + if (hasWorkProfile()) { mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed, targetDataLoader); + initialIntents, rList, filterLastUsed); } else { mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed, targetDataLoader); + initialIntents, rList, filterLastUsed); } return mChooserMultiProfilePagerAdapter; } - @Override protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = requireChooserRequest().isSendActionTarget(); + final boolean isSendAction = mViewModel.getChooserRequest().isSendActionTarget(); final EmptyState noWorkToPersonalEmptyState = new DevicePolicyBlockerEmptyState( @@ -492,21 +1381,27 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { ChooserGridAdapter adapter = createChooserGridAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mViewModel.getChooserRequest().getPayloadIntents(), initialIntents, rList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + ); return new ChooserMultiProfilePagerAdapter( /* context */ this, - adapter, + 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, @@ -516,29 +1411,39 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + boolean filterLastUsed) { int selectedProfile = findSelectedProfile(); ChooserGridAdapter personalAdapter = createChooserGridAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mViewModel.getChooserRequest().getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + ); ChooserGridAdapter workAdapter = createChooserGridAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mViewModel.getChooserRequest().getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, rList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().workProfileUserHandle + ); return new ChooserMultiProfilePagerAdapter( /* 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(requireAnnotatedUserHandles().workProfileUserHandle), () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), selectedProfile, @@ -549,12 +1454,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private int findSelectedProfile() { - int selectedProfile = getSelectedProfileExtra(); - if (selectedProfile == -1) { - selectedProfile = getProfileForUser( - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); - } - return selectedProfile; + return getProfileForUser(requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } /** @@ -567,7 +1467,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements .getUserInfo(UserHandle.myUserId()).isManagedProfile(); } - @Override + //@Override protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override @@ -580,6 +1480,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /** * Update UI to reflect changes in data. */ + @Override public void handlePackagesChanged() { handlePackagesChanged(/* listAdapter */ null); } @@ -597,23 +1498,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } else { listAdapter.handlePackagesChanged(); } - updateProfileViewButton(); - } - - @Override - protected void onResume() { - super.onResume(); - Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); - mFinishWhenStopped = false; - mRefinementManager.onActivityResume(); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + + if (mSystemWindowInsets != null) { + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + } ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); if (viewPager.isLayoutRtl()) { - mMultiProfilePagerAdapter.setupViewPager(viewPager); + mChooserMultiProfilePagerAdapter.setupViewPager(viewPager); } mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); @@ -643,7 +1541,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void updateTabPadding() { - if (shouldShowTabs()) { + 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 @@ -703,45 +1601,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return resolver.query(uri, null, null, null, null); } - @Override - protected void onStop() { - super.onStop(); - if (mRefinementManager != null) { - 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) { - ChooserRequestParameters chooserRequest = getChooserRequest(); - if (chooserRequest == null) { - return defIntent; - } + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); Intent result = defIntent; if (chooserRequest.getReplacementExtras() != null) { @@ -765,32 +1632,20 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return result; } - @Override - public void onActivityStarted(TargetInfo cti) { - ChooserRequestParameters chooserRequest = requireChooserRequest(); - if (chooserRequest.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 { - chooserRequest.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() { - ChooserRequestParameters chooserRequest = requireChooserRequest(); + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); if (!chooserRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. - UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) - ? requireAnnotatedUserHandles().workProfileUserHandle - : requireAnnotatedUserHandles().personalProfileUserHandle; - if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { + if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, new ArrayList<>(chooserRequest.getCallerChooserTargets()), @@ -801,28 +1656,18 @@ 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); + return mActivityModel.getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, + true); } private void showTargetDetails(TargetInfo targetInfo) { @@ -837,8 +1682,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() - ? requireChooserRequest().getTargetIntentFilter() : null; + IntentFilter intentFilter; + intentFilter = targetInfo.isSelectableTargetInfo() + ? mViewModel.getChooserRequest().getShareTargetFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; String shortcutIdKey = targetInfo.getDirectShareShortcutId(); @@ -855,22 +1701,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, - requireChooserRequest().getRefinementIntentSender(), + mViewModel.getChooserRequest().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 @@ -893,8 +1742,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 @@ -914,7 +1778,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetInfo.getResolveInfo().activityInfo.processName, which, /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - requireChooserRequest().getCallerChooserTargets().size(), + mViewModel.getChooserRequest().getCallerChooserTargets().size(), targetInfo.getHashedTargetIdForMetrics(this), targetInfo.isPinned(), mIsSuccessfullySelected, @@ -951,7 +1815,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mIsSuccessfullySelected, selectionCost ); - return; } } } @@ -973,13 +1836,6 @@ 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) { mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height); } @@ -1001,7 +1857,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (info != null) { sendClickToAppPredictor(info); final ResolveInfo ri = info.getResolveInfo(); - Intent targetIntent = mLogic.getTargetIntent(); + Intent targetIntent = mViewModel.getChooserRequest().getTargetIntent(); if (ri != null && ri.activityInfo != null && targetIntent != null) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); @@ -1029,7 +1885,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (targetIntent == null) { return; } - Intent originalTargetIntent = new Intent(requireChooserRequest().getTargetIntent()); + 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) { @@ -1103,61 +1959,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ? 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 requireChooserRequest().getFilteredComponentNames().contains(name); - } - - @Override - public boolean isComponentPinned(ComponentName name) { - return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); - } - } - @VisibleForTesting public ChooserGridAdapter createChooserGridAdapter( Context context, @@ -1165,9 +1970,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Intent[] initialIntents, List<ResolveInfo> rList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { - ChooserRequestParameters parameters = requireChooserRequest(); + UserHandle userHandle) { + ChooserRequest request = mViewModel.getChooserRequest(); ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, @@ -1176,17 +1980,17 @@ public class ChooserActivity extends Hilt_ChooserActivity implements filterLastUsed, createListController(userHandle), userHandle, - mLogic.getTargetIntent(), - parameters.getReferrerFillInIntent(), - mMaxTargetsPerRow, - targetDataLoader); + request.getTargetIntent(), + request.getReferrerFillInIntent(), + mMaxTargetsPerRow + ); return new ChooserGridAdapter( context, new ChooserGridAdapter.ChooserActivityDelegate() { @Override public boolean shouldShowTabs() { - return ChooserActivity.this.shouldShowTabs(); + return hasWorkProfile(); } @Override @@ -1212,13 +2016,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements showTargetDetails(longPressedTargetInfo); } } - - @Override - public void updateProfileViewButton(View newButtonFromProfileRow) { - mProfileView = newButtonFromProfileRow; - mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); - ChooserActivity.this.updateProfileViewButton(); - } }, chooserListAdapter, shouldShowContentPreview(), @@ -1237,8 +2034,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements UserHandle userHandle, Intent targetIntent, Intent referrerFillInIntent, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { + int maxTargetsPerRow) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; @@ -1253,30 +2049,35 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetIntent, referrerFillInIntent, this, - context.getPackageManager(), + mPackageManager, getEventLog(), maxTargetsPerRow, initialIntentsUserSpace, - targetDataLoader, + mTargetDataLoader, () -> { ProfileRecord record = getProfileRecord(userHandle); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - }); + }, + mFeatureFlags); } - @Override protected Unit onWorkProfileStatusUpdated() { UserHandle workUser = requireAnnotatedUserHandles().workProfileUserHandle; ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - return super.onWorkProfileStatusUpdated(); + if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals( + requireAnnotatedUserHandles().workProfileUserHandle)) { + mChooserMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + return Unit.INSTANCE; } - @Override @VisibleForTesting protected ChooserListController createListController(UserHandle userHandle) { AppPredictor appPredictor = getAppPredictor(userHandle); @@ -1284,8 +2085,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (appPredictor != null) { resolverComparator = new AppPredictionServiceResolverComparator( this, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), + mViewModel.getChooserRequest().getTargetIntent(), + mViewModel.getChooserRequest().getLaunchedFromPackage(), appPredictor, userHandle, getEventLog(), @@ -1295,8 +2096,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements resolverComparator = new ResolverRankerServiceResolverComparator( this, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), + mViewModel.getChooserRequest().getTargetIntent(), + mViewModel.getChooserRequest().getReferrerPackage(), null, getEventLog(), getResolverRankerServiceUserHandleList(userHandle), @@ -1305,12 +2106,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return new ChooserListController( this, - mPm, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), + mPackageManager, + mViewModel.getChooserRequest().getTargetIntent(), + mViewModel.getChooserRequest().getReferrerPackage(), requireAnnotatedUserHandles().userIdOfCallingApp, resolverComparator, - getQueryIntentsUser(userHandle)); + getQueryIntentsUser(userHandle), + mViewModel.getChooserRequest().getFilteredComponentNames(), + mPinnedSharedPrefs); } @VisibleForTesting @@ -1319,11 +2122,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private ChooserActionFactory createChooserActionFactory() { - ChooserRequestParameters request = requireChooserRequest(); + ChooserRequest request = mViewModel.getChooserRequest(); return new ChooserActionFactory( this, request.getTargetIntent(), - request.getReferrerPackageName(), + request.getLaunchedFromPackage(), request.getChooserActions(), request.getModifyShareAction(), mImageEditor, @@ -1354,12 +2157,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mFinishWhenStopped = true; } }, + mShareResultSender, (status) -> { if (status != null) { setResult(status); } finish(); - }); + }, + mClipboardManager); } /* @@ -1404,8 +2209,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; @@ -1432,7 +2236,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; int rowsToShow = gridAdapter.getSystemRowCount() - + gridAdapter.getProfileRowCount() + gridAdapter.getServiceTargetRowCount() + gridAdapter.getCallerAndRankedTargetRowCount(); @@ -1455,7 +2258,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements offset += stickyContentPreview.getHeight(); } - if (shouldShowTabs()) { + if (hasWorkProfile()) { offset += findViewById(com.android.internal.R.id.tabs).getHeight(); } @@ -1512,7 +2315,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return PROFILE_PERSONAL; } - @Override protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); maybeSetupGlobalLayoutListener(); @@ -1582,7 +2384,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"); @@ -1597,7 +2399,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 = 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 = @@ -1635,7 +2438,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void maybeSetupGlobalLayoutListener() { - if (shouldShowTabs()) { + if (hasWorkProfile()) { return; } final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -1669,9 +2472,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (!shouldShowContentPreview()) { return false; } - boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + boolean isEmpty = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( UserHandle.of(UserHandle.myUserId())).getCount() == 0; - return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) + return (mFeatureFlags.scrollablePreview() || hasWorkProfile()) && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } @@ -1690,7 +2493,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { - ChooserRequestParameters chooserRequest = getChooserRequest(); + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); return (chooserRequest != null) && chooserRequest.isSendActionTarget(); } @@ -1735,34 +2538,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 (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 @@ -1772,14 +2563,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - if (shouldShowTabs()) { + if (hasWorkProfile()) { mChooserMultiProfilePagerAdapter .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); } - WindowInsets result = super.onApplyWindowInsets(v, insets); + WindowInsets result = super_onApplyWindowInsets(v, insets); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.requestLayout(); } @@ -1798,7 +2588,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) { @@ -1813,7 +2602,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - @Override protected void maybeLogProfileChange() { getEventLog().logSharesheetProfileChanged(); } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index 7bc39a24..84b7d9a9 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -1,87 +1,23 @@ package com.android.intentresolver.v2 -import android.app.Activity -import android.content.Intent -import android.util.Log import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting -import com.android.intentresolver.ChooserRequestParameters -import com.android.intentresolver.R -import com.android.intentresolver.icons.TargetDataLoader -import com.android.intentresolver.v2.util.mutableLazy - -private const val TAG = "ChooserActivityLogic" /** * Activity logic for [ChooserActivity]. * * TODO: Make this class no longer open once [ChooserActivity] no longer needs to cast to access - * [chooserRequestParameters]. For now, this class being open is better than using reflection - * there. + * [chooserRequest]. For now, this class being open is better than using reflection there. */ @OpenForTesting open class ChooserActivityLogic( tag: String, - activityProvider: () -> ComponentActivity, + activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, - targetDataLoaderProvider: () -> TargetDataLoader, - private val onPreInitialization: () -> Unit, ) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl( tag, - activityProvider, + activity, onWorkProfileStatusUpdated, - ) { - - override val targetIntent: Intent by lazy { chooserRequestParameters?.targetIntent ?: Intent() } - - override val resolvingHome: Boolean = false - - override val title: CharSequence? by lazy { chooserRequestParameters?.title } - - override val defaultTitleResId: Int by lazy { - chooserRequestParameters?.defaultTitleResource ?: 0 - } - - override val initialIntents: List<Intent>? by lazy { - chooserRequestParameters?.initialIntents?.toList() - } - - override val supportsAlwaysUseOption: Boolean = false - - override val targetDataLoader: TargetDataLoader by lazy { targetDataLoaderProvider() } - - override val themeResId: Int = R.style.Theme_DeviceDefault_Chooser - - private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } - override val profileSwitchMessage: String? by _profileSwitchMessage - - override val payloadIntents: List<Intent> by lazy { - buildList { - add(targetIntent) - chooserRequestParameters?.additionalTargets?.let { addAll(it) } - } - } - - val chooserRequestParameters: ChooserRequestParameters? by lazy { - try { - ChooserRequestParameters( - (activity as Activity).intent, - referrerPackageName, - (activity as Activity).referrer, - ) - } catch (e: IllegalArgumentException) { - Log.e(tag, "Caller provided invalid Chooser request parameters", e) - null - } - } - - override fun preInitialization() { - onPreInitialization() - } - - override fun clearProfileSwitchMessage() { - _profileSwitchMessage.setLazy(null) - } -} + ) diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt new file mode 100644 index 00000000..17bc2731 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -0,0 +1,81 @@ +/* + * 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/ChooserListController.java b/java/src/com/android/intentresolver/v2/ChooserListController.java new file mode 100644 index 00000000..467f343b --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserListController.java @@ -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.v2; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +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; + +public class ChooserListController extends ResolverListController { + private final List<ComponentName> mFilteredComponents; + private final SharedPreferences mPinnedComponents; + + public ChooserListController( + Context context, + PackageManager pm, + Intent targetIntent, + String referrerPackageName, + int launchedFromUid, + AbstractResolverComparator resolverComparator, + UserHandle queryIntentsAsUser, + List<ComponentName> filteredComponents, + SharedPreferences pinnedComponents) { + super( + context, + pm, + targetIntent, + referrerPackageName, + launchedFromUid, + resolverComparator, + queryIntentsAsUser); + mFilteredComponents = filteredComponents; + mPinnedComponents = pinnedComponents; + } + + @Override + public boolean isComponentFiltered(ComponentName name) { + return mFilteredComponents.contains(name); + } + + @Override + public boolean isComponentPinned(ComponentName name) { + return mPinnedComponents.getBoolean(name.flattenToString(), false); + } +} diff --git a/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt new file mode 100644 index 00000000..2f8ccf77 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ChooserMutableActionFactory.kt @@ -0,0 +1,54 @@ +/* + * 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/IntentForwarding.kt b/java/src/com/android/intentresolver/v2/IntentForwarding.kt new file mode 100644 index 00000000..3d366d10 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/IntentForwarding.kt @@ -0,0 +1,111 @@ +/* + * 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.Manifest +import android.Manifest.permission.INTERACT_ACROSS_USERS +import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.PermissionChecker +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +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 javax.inject.Inject +import javax.inject.Singleton + +private const val TAG: String = "IntentForwarding" + +@Singleton +class IntentForwarding +@Inject +constructor( + private val resources: DevicePolicyResources, + private val userManager: UserManager, + private val packageManager: PackageManager +) { + + fun forwardMessageFor(intent: Intent): String? { + val contentUserHint = intent.contentUserHint + if ( + contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId() + ) { + val originUserInfo = userManager.getUserInfo(contentUserHint) + val originIsManaged = originUserInfo?.isManagedProfile ?: false + val targetIsManaged = userManager.isManagedProfile + return when { + originIsManaged && !targetIsManaged -> resources.forwardToPersonalMessage + !originIsManaged && targetIsManaged -> resources.forwardToWorkMessage + else -> null + } + } + return null + } + + private fun isPermissionGranted(permission: String, uid: Int) = + ActivityManager.checkComponentPermission( + /* permission = */ permission, + /* uid = */ uid, + /* owningUid= */ -1, + /* exported= */ true + ) + + /** + * Returns whether the package has the necessary permissions to interact across profiles on + * behalf of a given user. + * + * This means meeting the following condition: + * * The app's [ApplicationInfo.crossProfile] flag must be true, and at least one of the + * following conditions must be fulfilled + * * `Manifest.permission.INTERACT_ACROSS_USERS_FULL` granted. + * * `Manifest.permission.INTERACT_ACROSS_USERS` granted. + * * `Manifest.permission.INTERACT_ACROSS_PROFILES` granted, or the corresponding AppOps + * `android:interact_across_profiles` is set to "allow". + */ + fun canAppInteractAcrossProfiles(context: Context, packageName: String): Boolean { + val applicationInfo: ApplicationInfo + try { + applicationInfo = packageManager.getApplicationInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + Log.e(TAG, "Package $packageName does not exist on current user.") + return false + } + if (!applicationInfo.crossProfile) { + return false + } + + val packageUid = applicationInfo.uid + + if (isPermissionGranted(INTERACT_ACROSS_USERS_FULL, packageUid) == PERMISSION_GRANTED) { + return true + } + if (isPermissionGranted(INTERACT_ACROSS_USERS, packageUid) == PERMISSION_GRANTED) { + return true + } + return PermissionChecker.checkPermissionForPreflight( + context, + Manifest.permission.INTERACT_ACROSS_PROFILES, + PermissionChecker.PID_UNKNOWN, + packageUid, + packageName + ) == PERMISSION_GRANTED + } +} diff --git a/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt new file mode 100644 index 00000000..c6c977f6 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/JavaFlowHelper.kt @@ -0,0 +1,28 @@ +/* + * 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. + */ + +@file:JvmName("JavaFlowHelper") + +package com.android.intentresolver.v2 + +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +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/v2/ProfileAvailability.kt b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt new file mode 100644 index 00000000..4d689724 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ProfileAvailability.kt @@ -0,0 +1,79 @@ +/* + * 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 com.android.intentresolver.v2.domain.interactor.UserInteractor +import com.android.intentresolver.v2.shared.model.Profile +import kotlin.time.Duration.Companion.seconds +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.launch +import kotlinx.coroutines.withTimeout + +/** Provides availability status for profiles */ +class ProfileAvailability( + private val scope: CoroutineScope, + private val userInteractor: UserInteractor +) { + private val availability = + userInteractor.availability.stateIn(scope, SharingStarted.Eagerly, mapOf()) + + /** Used by WorkProfilePausedEmptyStateProvider */ + var waitingToEnableProfile = false + private set + + 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 + + /** Used by WorkProfilePausedEmptyStateProvider */ + fun requestQuietModeState(profile: Profile, quietMode: Boolean) { + val enableProfile = !quietMode + + // Check if the profile is already in the correct state + if (isAvailable(profile) == enableProfile) { + return // No-op + } + + // Support existing code + if (enableProfile) { + 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 + } + job.invokeOnCompletion { + waitingToEnableProfile = false + } + waitJob = job + } + + // Apply the change + scope.launch { userInteractor.updateState(profile, enableProfile) } + } +}
\ No newline at end of file diff --git a/java/src/com/android/intentresolver/v2/ProfileHelper.kt b/java/src/com/android/intentresolver/v2/ProfileHelper.kt new file mode 100644 index 00000000..784096b4 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ProfileHelper.kt @@ -0,0 +1,74 @@ +/* +* 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 index 2ba50ec3..0182fc89 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -16,34 +16,29 @@ package com.android.intentresolver.v2; -import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; 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.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 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.Collections.emptyList; import static java.util.Objects.requireNonNull; -import static java.util.Objects.requireNonNullElse; -import android.app.ActivityManager; 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; @@ -51,7 +46,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; @@ -83,15 +77,13 @@ 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.viewmodel.CreationExtras; import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.AnnotatedUserHandles; @@ -105,24 +97,40 @@ 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.MultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.v2.MultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.v2.MultiProfilePagerAdapter.Profile; 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.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.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; @@ -131,6 +139,9 @@ 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 @@ -138,23 +149,18 @@ import java.util.Set; * 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 { - private final List<Runnable> mInit = new ArrayList<>(); - + @Inject public PackageManager mPackageManager; + @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public IntentForwarding mIntentForwarding; + private ResolverRequest mResolverRequest; + private ActivityModel mActivityModel; protected ActivityLogic mLogic; - - private DevicePolicyResources mDevicePolicyResources; - - public ResolverActivity() { - mIsIntentPicker = getClass().equals(ResolverActivity.class); - } - - protected ResolverActivity(boolean isIntentPicker) { - mIsIntentPicker = isIntentPicker; - } + protected TargetDataLoader mTargetDataLoader; + private boolean mResolvingHome; private Button mAlwaysButton; private Button mOnceButton; @@ -163,9 +169,7 @@ public class ResolverActivity extends FragmentActivity implements private int mLayoutId; private PickTargetOptionRequest mPickOptionRequest; // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. - private final boolean mIsIntentPicker; protected ResolverDrawerLayout mResolverDrawerLayout; - protected PackageManager mPm; private static final String TAG = "ResolverActivity"; private static final boolean DEBUG = false; @@ -176,64 +180,33 @@ 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; - @VisibleForTesting - protected MultiProfilePagerAdapter mMultiProfilePagerAdapter; - + 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; @Nullable private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override public void onSomePackagesChanged() { listAdapter.handlePackagesChanged(); - updateProfileViewButton(); } @Override @@ -244,65 +217,63 @@ public class ResolverActivity extends FragmentActivity implements } }; } - protected interface Initializer { - void initialize(ActivityLogic value); + protected ActivityModel createActivityModel() { + return ActivityModel.createFrom(this); } - protected void setLogic(ActivityLogic logic) { - mLogic = logic; + @VisibleForTesting + protected ActivityLogic createActivityLogic() { + return new ResolverActivityLogic( + TAG, + /* activity = */ this, + this::onWorkProfileStatusUpdated); } - protected void addInitializer(Runnable initializer) { - mInit.add(initializer); + @NonNull + @Override + public CreationExtras getDefaultViewModelCreationExtras() { + return addDefaultArgs( + super.getDefaultViewModelCreationExtras(), + new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, ActivityModel.createFrom(this))); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (isFinishing()) { - // Performing a clean exit: - // Skip initializing anything. - return; + 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(); } - mDevicePolicyResources = new DevicePolicyResources(getApplication().getResources(), - requireNonNull(getSystemService(DevicePolicyManager.class))); - setLogic(new ResolverActivityLogic( - TAG, - () -> this, - this::onWorkProfileStatusUpdated)); - addInitializer(this::init); - } - @Override - protected final void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - mInit.forEach(Runnable::run); - - if (savedInstanceState != null) { - resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); - } - mMultiProfilePagerAdapter.clearInactiveProfileCache(); + 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() { - setTheme(mLogic.getThemeResId()); - mLogic.preInitialization(); - - Intent intent = mLogic.getTargetIntent(); - List<Intent> initialIntents = mLogic.getInitialIntents(); - TargetDataLoader targetDataLoader = mLogic.getTargetDataLoader(); - - // Calling UID did not have valid permissions - if (mLogic.getAnnotatedUserHandles() == null) { - finish(); - return; - } - - mPm = getPackageManager(); + 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 @@ -312,15 +283,14 @@ public class ResolverActivity extends FragmentActivity implements // 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 = mLogic.getSupportsAlwaysUseOption() && !isVoiceInteraction() - && !shouldShowTabs() && !hasCloneProfile(); + boolean filterLastUsed = !isVoiceInteraction() + && !hasWorkProfile() && !hasCloneProfile(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - requireNonNullElse(initialIntents, emptyList()).toArray(new Intent[0]), - /* resolutionList = */ null, - filterLastUsed, - targetDataLoader + new Intent[0], + /* resolutionList = */ mResolverRequest.getResolutionList(), + filterLastUsed ); - if (configureContentView(targetDataLoader)) { + if (configureContentView(mTargetDataLoader)) { return; } @@ -354,7 +324,7 @@ public class ResolverActivity extends FragmentActivity implements } }); - boolean hasTouchScreen = getPackageManager() + boolean hasTouchScreen = mPackageManager .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); if (isVoiceInteraction() || !hasTouchScreen) { @@ -368,12 +338,6 @@ 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(); - } - final Set<String> categories = intent.getCategories(); MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED @@ -382,19 +346,31 @@ 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 (hasWorkProfile()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } else { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( - initialIntents, resolutionList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } return resolverMultiProfilePagerAdapter; } @@ -448,9 +424,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) { @@ -492,7 +466,7 @@ public class ResolverActivity extends FragmentActivity implements public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() + if (hasWorkProfile() && !useLayoutWithDefault() && !shouldUseMiniResolver()) { updateIntentPickerPaddings(); } @@ -525,7 +499,7 @@ public class ResolverActivity extends FragmentActivity implements } final Intent intent = getIntent(); if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mLogic.getResolvingHome() && !mRetainInOnStop) { + && !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 @@ -553,6 +527,7 @@ public class ResolverActivity extends FragmentActivity implements } } + // referenced by layout XML: android:onClick="onButtonClick" public void onButtonClick(View v) { final int id = v.getId(); ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); @@ -570,8 +545,8 @@ public class ResolverActivity extends FragmentActivity implements } ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() .resolveInfoForPosition(which, hasIndexBeenFiltered); - if (mLogic.getResolvingHome() && hasManagedProfile() && !supportsManagedProfiles(ri)) { - String launcherName = ri.activityInfo.loadLabel(getPackageManager()).toString(); + if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { + String launcherName = ri.activityInfo.loadLabel(mPackageManager).toString(); Toast.makeText(this, mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), Toast.LENGTH_LONG).show(); @@ -584,15 +559,12 @@ public class ResolverActivity extends FragmentActivity implements return; } if (onTargetSelected(target, always)) { - if (always && mLogic.getSupportsAlwaysUseOption()) { + if (always) { MetricsLogger.action( this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); - } else if (mLogic.getSupportsAlwaysUseOption()) { - 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() @@ -602,9 +574,6 @@ public class ResolverActivity extends FragmentActivity implements } } - /** - * Replace me in subclasses! - */ @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { return defIntent; @@ -613,7 +582,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 (hasWorkProfile()) { final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); if (rdl != null) { rdl.setMaxCollapsedHeight(getResources() @@ -628,8 +597,7 @@ public class ResolverActivity extends FragmentActivity implements final ResolveInfo ri = target.getResolveInfo(); final Intent intent = target != null ? target.getResolvedIntent() : null; - if (intent != null && (mLogic.getSupportsAlwaysUseOption() - || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) + if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/ && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { // Build a reasonable intent filter, based on what matched. IntentFilter filter = new IntentFilter(); @@ -672,7 +640,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 @@ -730,7 +698,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, @@ -748,7 +716,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()); @@ -757,7 +725,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 { @@ -771,21 +740,11 @@ public class ResolverActivity extends FragmentActivity implements } } - if (target != null) { - safelyStartActivity(target); - - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - if (target.isSuspended()) { - return false; - } - } - - return true; - } + safelyStartActivity(target); - 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 @@ -797,34 +756,23 @@ 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, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), + mResolverRequest.getIntent(), + mActivityModel.getReferrerPackage(), null, null, getResolverRankerServiceUserHandleList(userHandle), null); return new ResolverListController( this, - mPm, - mLogic.getTargetIntent(), - mLogic.getReferrerPackageName(), - requireAnnotatedUserHandles().userIdOfCallingApp, + mPackageManager, + mActivityModel.getIntent(), + mActivityModel.getReferrerPackage(), + mActivityModel.getLaunchedFromUid(), resolverComparator, getQueryIntentsUser(userHandle)); } @@ -839,13 +787,30 @@ public class ResolverActivity extends FragmentActivity implements 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 (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. @@ -858,7 +823,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 (hasWorkProfile()) { textView.setGravity(Gravity.CENTER); } stub.addView(textView); @@ -866,9 +831,6 @@ public class ResolverActivity extends FragmentActivity implements } protected void resetButtonBar() { - if (!mLogic.getSupportsAlwaysUseOption()) { - 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"); @@ -921,21 +883,13 @@ public class ResolverActivity extends FragmentActivity implements protected void maybeLogProfileChange() {} - // @NonFinalForTesting - @VisibleForTesting - protected MyUserIdProvider createMyUserIdProvider() { - return new MyUserIdProvider(); - } - - // @NonFinalForTesting @VisibleForTesting protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { return new CrossProfileIntentsChecker(getContentResolver()); } protected Unit onWorkProfileStatusUpdated() { - if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals( - requireAnnotatedUserHandles().workProfileUserHandle)) { + if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); @@ -951,8 +905,7 @@ public class ResolverActivity extends FragmentActivity implements Intent[] initialIntents, List<ResolveInfo> resolutionList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() && userHandle.equals(requireAnnotatedUserHandles().personalProfileUserHandle) ? requireAnnotatedUserHandles().cloneProfileUserHandle : userHandle; @@ -964,26 +917,10 @@ public class ResolverActivity extends FragmentActivity implements filterLastUsed, createListController(userHandle), userHandle, - mLogic.getTargetIntent(), + mResolverRequest.getIntent(), this, initialIntentsUserSpace, - targetDataLoader); - } - - 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( @@ -991,7 +928,7 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, + new ResolverWorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, mLogic.getWorkProfileAvailabilityManager(), /* onSwitchOnWorkSelectedListener= */ () -> { @@ -1021,36 +958,40 @@ public class ResolverActivity extends FragmentActivity implements createResolverMultiProfilePagerAdapterForOneProfile( Intent[] initialIntents, List<ResolveInfo> resolutionList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - ResolverListAdapter adapter = createResolverListAdapter( + boolean filterLastUsed) { + ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mResolverRequest.getPayloadIntents(), initialIntents, resolutionList, filterLastUsed, - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + ); 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, requireAnnotatedUserHandles().cloneProfileUserHandle); } private UserHandle getIntentUser() { - return getIntent().hasExtra(EXTRA_CALLING_USER) - ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch; + return Objects.requireNonNullElse(mResolverRequest.getCallingUser(), + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch); } 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. @@ -1073,27 +1014,38 @@ public class ResolverActivity extends FragmentActivity implements // resolver list. So filterLastUsed should be false for the other profile. ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mResolverRequest.getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, resolutionList, (filterLastUsed && UserHandle.myUserId() == requireAnnotatedUserHandles().personalProfileUserHandle.getIdentifier()), - /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle, - targetDataLoader); + /* userHandle */ requireAnnotatedUserHandles().personalProfileUserHandle + ); UserHandle workProfileUserHandle = requireAnnotatedUserHandles().workProfileUserHandle; ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, - mLogic.getPayloadIntents(), + mResolverRequest.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), () -> mLogic.getWorkProfileAvailabilityManager().isQuietModeEnabled(), selectedProfile, @@ -1104,23 +1056,20 @@ public class ResolverActivity extends FragmentActivity implements /** * 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 = mResolverRequest.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() { + protected final @ProfileType int getCurrentProfile() { UserHandle launchUser = requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch; UserHandle personalUser = requireAnnotatedUserHandles().personalProfileUserHandle; return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; @@ -1144,24 +1093,6 @@ public class ResolverActivity extends FragmentActivity implements 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. - mLogic.clearProfileSwitchMessage(); - - onTargetSelected(dri, false); - finish(); - } - private void updateIntentPickerPaddings() { View titleCont = findViewById(com.android.internal.R.id.title_container); titleCont.setPadding( @@ -1219,28 +1150,8 @@ public class ResolverActivity extends FragmentActivity implements return new Option(getOrLoadDisplayLabel(target), index); } - @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); - } - } - protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { - final ActionTitle title = mLogic.getResolvingHome() + final ActionTitle title = mResolvingHome ? ActionTitle.HOME : ActionTitle.forAction(intent.getAction()); @@ -1261,12 +1172,6 @@ public class ResolverActivity extends FragmentActivity implements } } - final void dismiss() { - if (!isFinishing()) { - finish(); - } - } - @Override protected final void onRestart() { super.onRestart(); @@ -1297,17 +1202,6 @@ public class ResolverActivity extends FragmentActivity implements } } mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - updateProfileViewButton(); - } - - @Override - protected final void onStart() { - super.onStart(); - - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (hasWorkProfile()) { - mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); - } } @Override @@ -1319,6 +1213,15 @@ public class ResolverActivity extends FragmentActivity implements } } + @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) { @@ -1340,7 +1243,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) { @@ -1358,7 +1261,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 (hasCloneProfile() + && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) { mAlwaysButton.setEnabled(false); return; } @@ -1384,16 +1288,14 @@ 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) == 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 = mResolverRequest.isAudioCaptureDevice(); enabled = !hasAudioCapture; } } @@ -1406,10 +1308,8 @@ public class ResolverActivity extends FragmentActivity implements if (isAutolaunching()) { return; } - if (mIsIntentPicker) { - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .setUseLayoutWithDefault(useLayoutWithDefault()); - } + mMultiProfilePagerAdapter.setUseLayoutWithDefault(useLayoutWithDefault()); + if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); } else { @@ -1458,39 +1358,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. - String profileSwitchMessage = mLogic.getProfileSwitchMessage(); - if (profileSwitchMessage != null) { - Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); - } - try { - if (cti.startAsCaller(this, options, user.getIdentifier())) { - onActivityStarted(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - } catch (RuntimeException e) { - Slog.wtf(TAG, - "Unable to launch as uid " + requireAnnotatedUserHandles().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)) @@ -1511,7 +1378,7 @@ public class ResolverActivity extends FragmentActivity implements // 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(shouldShowTabs()); + boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildTabs(hasWorkProfile()); if (shouldUseMiniResolver()) { configureMiniResolverContent(targetDataLoader); @@ -1541,11 +1408,6 @@ public class ResolverActivity extends FragmentActivity implements mLayoutId = R.layout.miniresolver; setContentView(mLayoutId); - // TODO: try to dedupe and use the pager's `getActiveProfile()` instead of the activity - // `getCurrentProfile()` (or align them if they're not currently equivalent). If they truly - // need to be distinct here, then `getCurrentProfile()` should at *least* get a more - // specific name -- but note that checking `getCurrentProfile()` here, then following - // `getActiveProfile()` to find the "in/active adapter," is exactly the legacy behavior. boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; ResolverListAdapter sameProfileAdapter = @@ -1604,17 +1466,69 @@ public class ResolverActivity extends FragmentActivity implements && 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. There are exactly two tabs, for the "personal" and "work" profiles. - * 3. This profile only has web browser matches. - * 4. The other profile has a single non-browser match. + * 2. This profile only has web browser matches. + * 3. The other profile has a single non-browser match. */ private boolean shouldUseMiniResolver() { - if (!mIsIntentPicker) { - return false; - } if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } @@ -1652,50 +1566,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 (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) { @@ -1761,7 +1631,7 @@ public class ResolverActivity extends FragmentActivity implements } String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!canAppInteractCrossProfiles(packageName)) { + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { return false; } @@ -1776,131 +1646,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; + ResolverListAdapter inactiveListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; } - if (PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, - PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED) { - return true; + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; } - return false; - } - private boolean isAutolaunching() { - return !mRegistered && isFinishing(); - } + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + 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(mDevicePolicyResources.getPersonalTabLabel()); - personalButton.setContentDescription( - mDevicePolicyResources.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(mDevicePolicyResources.getWorkTabLabel()); - workButton.setContentDescription(mDevicePolicyResources.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 (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + 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(); - } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { + 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(); - }; + 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() { - if (!mIsIntentPicker) { - return; - } final View divider = findViewById(com.android.internal.R.id.divider); if (divider == null) { return; @@ -1909,29 +1714,11 @@ public class ResolverActivity extends FragmentActivity implements } private void resetCheckedItem() { - if (!mIsIntentPicker) { - return; - } mLastSelected = ListView.INVALID_POSITION; ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) .clearCheckedItemsInInactiveProfiles(); } - 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); - } - private void setupViewVisibilities() { ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); if (!mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { @@ -1957,10 +1744,7 @@ public class ResolverActivity extends FragmentActivity implements private void setupAdapterListView(ListView listView, ItemClickListener listener) { listView.setOnItemClickListener(listener); listView.setOnItemLongClickListener(listener); - - if (mLogic.getSupportsAlwaysUseOption()) { - listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); - } + listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); } /** @@ -1971,7 +1755,7 @@ public class ResolverActivity extends FragmentActivity implements && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { return; } - if (!shouldShowTabs() + if (!hasWorkProfile() && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { final TextView titleView = findViewById(com.android.internal.R.id.title); if (titleView != null) { @@ -1979,10 +1763,9 @@ public class ResolverActivity extends FragmentActivity implements } } - - CharSequence title = mLogic.getTitle() != null - ? mLogic.getTitle() - : getTitleForAction(mLogic.getTargetIntent(), mLogic.getDefaultTitleResId()); + 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); @@ -2027,19 +1810,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( - requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch - ).hasFilteredItem(); - return mLogic.getSupportsAlwaysUseOption() && 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; + return mMultiProfilePagerAdapter.getListAdapterForUserHandle( + requireAnnotatedUserHandles().tabOwnerUserHandleForLaunch + ).hasFilteredItem(); } final class ItemClickListener implements AdapterView.OnItemClickListener, @@ -2096,11 +1869,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 { @@ -2173,7 +1972,7 @@ public class ResolverActivity extends FragmentActivity implements private CharSequence getOrLoadDisplayLabel(TargetInfo info) { if (info.isDisplayResolveInfo()) { - mLogic.getTargetDataLoader().getOrLoadLabel((DisplayResolveInfo) info); + 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 index 0e2b25ec..7eb63ab3 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -1,81 +1,18 @@ package com.android.intentresolver.v2 -import android.content.Intent import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting -import com.android.intentresolver.R -import com.android.intentresolver.icons.DefaultTargetDataLoader -import com.android.intentresolver.icons.TargetDataLoader -import com.android.intentresolver.v2.util.mutableLazy /** Activity logic for [ResolverActivity]. */ @OpenForTesting open class ResolverActivityLogic( tag: String, - activityProvider: () -> ComponentActivity, + activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, ) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl( tag, - activityProvider, + activity, onWorkProfileStatusUpdated, - ) { - - override val targetIntent: Intent by lazy { - val intent = Intent(activity.intent) - 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.flags and Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS.inv()) - - // 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.flags and Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT != 0) { - intent.setFlags(intent.flags and Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT.inv()) - } - intent - } - - override val resolvingHome: Boolean by lazy { - targetIntent.action == Intent.ACTION_MAIN && - targetIntent.categories.singleOrNull() == Intent.CATEGORY_HOME - } - - override val title: CharSequence? = null - - override val defaultTitleResId: Int = 0 - - override val initialIntents: List<Intent>? = null - - override val supportsAlwaysUseOption: Boolean = true - - override val targetDataLoader: TargetDataLoader by lazy { - DefaultTargetDataLoader( - activity, - activity.lifecycle, - activity.intent.getBooleanExtra( - ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, - /* defaultValue = */ false, - ), - ) - } - - override val themeResId: Int = R.style.Theme_DeviceDefault_Resolver - - private val _profileSwitchMessage = mutableLazy { forwardMessageFor(targetIntent) } - override val profileSwitchMessage: String? by _profileSwitchMessage - - override val payloadIntents: List<Intent> by lazy { listOf(targetIntent) } - - override fun preInitialization() { - // Do nothing - } - - override fun clearProfileSwitchMessage() { - _profileSwitchMessage.setLazy(null) - } -} + ) diff --git a/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.kt new file mode 100644 index 00000000..15c5018a --- /dev/null +++ b/java/src/com/android/intentresolver/v2/annotation/JavaInterop.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.v2.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.") +annotation class JavaInterop diff --git a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt index 7debdf07..5719ff08 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/DevicePolicyResources.kt @@ -16,6 +16,8 @@ package com.android.intentresolver.v2.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_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 @@ -28,41 +30,71 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class DevicePolicyResources @Inject constructor( +class DevicePolicyResources +@Inject +constructor( @ApplicationOwned private val resources: Resources, devicePolicyManager: DevicePolicyManager ) { private val policyResources = devicePolicyManager.resources val personalTabLabel by lazy { - requireNotNull(policyResources.getString(RESOLVER_PERSONAL_TAB) { - resources.getString(R.string.resolver_personal_tab) - }) + requireNotNull( + policyResources.getString(RESOLVER_PERSONAL_TAB) { + resources.getString(R.string.resolver_personal_tab) + } + ) } val workTabLabel by lazy { - requireNotNull(policyResources.getString(RESOLVER_WORK_TAB) { - resources.getString(R.string.resolver_work_tab) - }) + requireNotNull( + policyResources.getString(RESOLVER_WORK_TAB) { + resources.getString(R.string.resolver_work_tab) + } + ) } val personalTabAccessibilityLabel by lazy { - requireNotNull(policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) { - resources.getString(R.string.resolver_personal_tab_accessibility) - }) + requireNotNull( + policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_personal_tab_accessibility) + } + ) } val workTabAccessibilityLabel by lazy { - requireNotNull(policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) { - resources.getString(R.string.resolver_work_tab_accessibility) - }) + requireNotNull( + policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_work_tab_accessibility) + } + ) + } + + val forwardToPersonalMessage: String? = + devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { + resources.getString(R.string.forward_intent_to_owner) + } + + val forwardToWorkMessage by lazy { + requireNotNull( + policyResources.getString(FORWARD_INTENT_TO_WORK) { + resources.getString(R.string.forward_intent_to_work) + } + ) } fun getWorkProfileNotSupportedMessage(launcherName: String): String { - return requireNotNull(policyResources.getString(RESOLVER_WORK_PROFILE_NOT_SUPPORTED, { - resources.getString( - R.string.activity_resolver_work_profiles_support, - launcherName) - }, launcherName)) + return requireNotNull( + policyResources.getString( + RESOLVER_WORK_PROFILE_NOT_SUPPORTED, + { + resources.getString( + R.string.activity_resolver_work_profiles_support, + launcherName + ) + }, + launcherName + ) + ) } -}
\ No newline at end of file +} diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt index fc82efee..a0b2d1ef 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserInfoExt.kt @@ -1,8 +1,8 @@ package com.android.intentresolver.v2.data.repository import android.content.pm.UserInfo -import com.android.intentresolver.v2.data.model.User -import com.android.intentresolver.v2.data.model.User.Role +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? = diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt index dc809b46..b57609e5 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepository.kt @@ -20,8 +20,8 @@ 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.model.User 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 @@ -39,17 +39,17 @@ import kotlinx.coroutines.withContext interface UserRepository { /** - * A [Flow] user profile groups. Each map contains the context user along with all members of + * 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<Map<UserHandle, User>> + 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]. */ - fun isAvailable(user: User): Flow<Boolean> + val availability: Flow<Map<User, Boolean>> /** * Request that availability be updated to the requested state. This currently includes toggling @@ -70,7 +70,7 @@ private const val TAG = "UserRepository" private data class UserWithState(val user: User, val available: Boolean) -private typealias UserStateMap = Map<UserHandle, UserWithState> +private typealias UserStates = List<UserWithState> /** Tracks and publishes state for the parent user and associated profiles. */ class UserRepositoryImpl @@ -110,15 +110,16 @@ constructor( override val cause: Throwable? = null ) : RuntimeException("$message: event=$event", cause) - private val usersWithState: Flow<UserStateMap> = + private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) + private val usersWithState: Flow<UserStates> = userEvents .onStart { emit(UserEvent(INITIALIZE, profileParent)) } - .onEach { Log.i("UserDataSource", "userEvent: $it") } - .runningFold<UserEvent, UserStateMap>(emptyMap()) { users, event -> + .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 -> createNewUserStateMap(profileParent) + INITIALIZE -> createNewUserStates(profileParent) ACTION_PROFILE_ADDED -> handleProfileAdded(event, users) ACTION_PROFILE_REMOVED -> handleProfileRemoved(event, users) ACTION_MANAGED_PROFILE_UNAVAILABLE, @@ -133,77 +134,67 @@ constructor( } catch (e: UserStateException) { Log.e(TAG, "An error occurred handling an event: ${e.event}", e) Log.e(TAG, "Attempting to recover...") - createNewUserStateMap(profileParent) + createNewUserStates(profileParent) } } - .onEach { Log.i("UserDataSource", "userStateMap: $it") } - .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + .distinctUntilChanged() + .onEach { Log.i(TAG, "userStateList: $it") } + .stateIn(sharingScope, SharingStarted.Eagerly, emptyList()) .filterNot { it.isEmpty() } - override val users: Flow<Map<UserHandle, User>> = - usersWithState.map { map -> map.mapValues { it.value.user } }.distinctUntilChanged() + override val users: Flow<List<User>> = + usersWithState.map { userStateMap -> userStateMap.map { it.user } }.distinctUntilChanged() - private val availability: Flow<Map<UserHandle, Boolean>> = - usersWithState.map { map -> map.mapValues { it.value.available } }.distinctUntilChanged() - - override fun isAvailable(user: User): Flow<Boolean> { - return isAvailable(user.handle) - } - - @VisibleForTesting - fun isAvailable(handle: UserHandle): Flow<Boolean> { - return availability.map { it[handle] ?: false } - } + 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 requestState(user.handle, available) - } - - @VisibleForTesting - suspend fun requestState(user: UserHandle, available: Boolean) { return withContext(backgroundDispatcher) { Log.i(TAG, "requestQuietModeEnabled: ${!available} for user $user") - userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user) + userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) } } - private fun handleAvailability(event: UserEvent, current: UserStateMap): UserStateMap { + 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[event.user] + current.firstOrNull { it.user.id == event.user.identifier } ?: throw UserStateException("User was not present in the map", event) - return current + (event.user to userEntry.copy(available = !event.quietMode)) + return current.update(event.user, userEntry.copy(available = !event.quietMode)) } - private fun handleProfileRemoved(event: UserEvent, current: UserStateMap): UserStateMap { - if (!current.containsKey(event.user)) { + 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.filterKeys { it != event.user } + return current.filter { it.user.id != event.user.identifier } } - private suspend fun handleProfileAdded(event: UserEvent, current: UserStateMap): UserStateMap { + 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 + (event.user to UserWithState(user, !event.quietMode)) + return current + UserWithState(user, !event.quietMode) } - private suspend fun createNewUserStateMap(user: UserHandle): UserStateMap { + private suspend fun createNewUserStates(user: UserHandle): UserStates { val profiles = readProfileGroup(user) - return profiles - .mapNotNull { userInfo -> - userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } - } - .associateBy { it.user.handle } + return profiles.mapNotNull { userInfo -> + userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } + } } - private suspend fun readProfileGroup(handle: UserHandle): List<UserInfo> { + private suspend fun readProfileGroup(member: UserHandle): List<UserInfo> { return withContext(backgroundDispatcher) { - @Suppress("DEPRECATION") userManager.getEnabledProfiles(handle.identifier) + @Suppress("DEPRECATION") userManager.getEnabledProfiles(member.identifier) } .toList() } diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt index 94f985e7..a84342f4 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserRepositoryModule.kt @@ -25,8 +25,11 @@ interface UserRepositoryModule { @Provides @Singleton @ProfileParent - fun profileParent(@ApplicationUser user: UserHandle, userManager: UserManager): UserHandle { - return userManager.getProfileParent(user) ?: user + fun profileParent( + @ApplicationContext context: Context, + userManager: UserManager + ): UserHandle { + return userManager.getProfileParent(context.user) ?: context.user } } diff --git a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt index 7ee78d91..3553744a 100644 --- a/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt +++ b/java/src/com/android/intentresolver/v2/data/repository/UserScopedService.kt @@ -2,7 +2,7 @@ package com.android.intentresolver.v2.data.repository import android.content.Context import androidx.core.content.getSystemService -import com.android.intentresolver.v2.data.model.User +import com.android.intentresolver.v2.shared.model.User /** * Provides cached instances of a [system service][Context.getSystemService] created with diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.kt new file mode 100644 index 00000000..72b604c2 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/domain/interactor/UserInteractor.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.v2.domain.interactor + +import android.os.UserHandle +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 javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +/** The high level User interface. */ +class UserInteractor +@Inject +constructor( + private val userRepository: UserRepository, + /** The specific [User] of the application which started this one. */ + @ApplicationUser val launchedAs: UserHandle, +) { + /** The profile group associated with the launching app user. */ + val profiles: Flow<List<Profile>> = + userRepository.users.map { users -> + users.mapNotNull { user -> + when (user.role) { + // PERSONAL includes CLONE + Role.PERSONAL -> { + Profile(Type.PERSONAL, user, users.firstOrNull { it.role == Role.CLONE }) + } + Role.CLONE -> { + /* ignore, included above */ + null + } + // others map 1:1 + else -> Profile(profileFromRole(user.role), user) + } + } + } + + /** The [Profile] of the application which started this one. */ + val launchedAsProfile: Flow<Profile> = + profiles.map { profiles -> + // The launching user profile is the one with a primary id or clone id + // matching the application user id. By definition there must always be exactly + // one matching profile for the current user. + profiles.single { + it.primary.id == launchedAs.identifier || it.clone?.id == launchedAs.identifier + } + } + /** + * Provides a flow to report on the availability of profile. An unavailable profile may be + * hidden or appear disabled within the app. + */ + val availability: Flow<Map<Profile, Boolean>> = + combine(profiles, userRepository.availability) { profiles, availability -> + profiles.associateWith { + availability.getOrDefault(it.primary, false) + } + } + + /** + * Request the profile state be updated. In the case of enabling, the operation could take + * significant time and/or require user input. + */ + suspend fun updateState(profile: Profile, available: Boolean) { + userRepository.requestState(profile.primary, available) + } + + private fun profileFromRole(role: Role): Type = + when (role) { + Role.PERSONAL -> Type.PERSONAL + Role.CLONE -> Type.PERSONAL /* CLONE maps to PERSONAL */ + Role.PRIVATE -> Type.PRIVATE + Role.WORK -> Type.WORK + } +} diff --git a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java index e9d1bb34..dfc46697 100644 --- a/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java +++ b/java/src/com/android/intentresolver/v2/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -29,11 +29,11 @@ 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 com.android.internal.R; import java.util.List; diff --git a/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java new file mode 100644 index 00000000..eaed35a7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/emptystate/ResolverWorkProfilePausedEmptyStateProvider.java @@ -0,0 +1,116 @@ +/* + * 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/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.kt new file mode 100644 index 00000000..6c36e6aa --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ext/CreationExtrasExt.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.v2.ext + +import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.bundleOf +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras + +/** + * Returns a new instance with additional [values] added to the existing default args Bundle (if + * present), otherwise adds a new entry with a copy of this bundle. + */ +fun CreationExtras.addDefaultArgs(vararg values: Pair<String, Parcelable>): CreationExtras { + val defaultArgs: Bundle = get(DEFAULT_ARGS_KEY) ?: Bundle() + defaultArgs.putAll(bundleOf(*values)) + return MutableCreationExtras(this).apply { set(DEFAULT_ARGS_KEY, defaultArgs) } +} diff --git a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt new file mode 100644 index 00000000..8c2d7277 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ext/IntentExt.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.v2.ext + +import android.content.Intent +import java.util.function.Predicate + +/** Applies an operation on this Intent if matches the given filter. */ +inline fun Intent.ifMatch( + predicate: Predicate<Intent>, + crossinline block: Intent.() -> Unit +): Intent { + if (predicate.test(this)) { + apply(block) + } + return this +} + +/** True if the Intent has one of the specified actions. */ +fun Intent.hasAction(vararg actions: String): Boolean = action in actions + +/** True if the Intent has a specific component target */ +fun Intent.hasComponent(): Boolean = (component != null) + +/** True if the Intent has a single matching category. */ +fun Intent.hasSingleCategory(category: String) = categories.singleOrNull() == category + +/** True if the Intent is a SEND or SEND_MULTIPLE action. */ +fun Intent.hasSendAction() = hasAction(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE) + +/** True if the Intent resolves to the special Home (Launcher) component */ +fun Intent.isHomeIntent() = hasAction(Intent.ACTION_MAIN) && hasSingleCategory(Intent.CATEGORY_HOME) diff --git a/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt b/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt new file mode 100644 index 00000000..b0ec97f4 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ext/ParcelExt.kt @@ -0,0 +1,27 @@ +/* + * 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.ext + +import android.os.Parcel + +inline fun <reified T> Parcel.requireParcelable(): T { + return requireNotNull(readParcelable<T>()) { "A non-value required from this parcel was null!" } +} + +inline fun <reified T> Parcel.readParcelable(): T? { + return readParcelable(T::class.java.classLoader, T::class.java) +} diff --git a/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt new file mode 100644 index 00000000..9ca9d871 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/platform/AppPredictionModule.kt @@ -0,0 +1,45 @@ +/* + * 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.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 +import javax.inject.Singleton + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppPredictionAvailable + +@Module +@InstallIn(SingletonComponent::class) +object AppPredictionModule { + + /** + * 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/profiles/AdapterBinder.java b/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java new file mode 100644 index 00000000..c5b35273 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/AdapterBinder.java @@ -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.v2.profiles; + +/** + * 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); +} diff --git a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java index de0a9426..0ee9d141 100644 --- a/java/src/com/android/intentresolver/v2/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/profiles/ChooserMultiProfilePagerAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 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.v2; +package com.android.intentresolver.v2.profiles; import android.content.Context; import android.os.UserHandle; @@ -32,7 +32,6 @@ import com.android.intentresolver.R; 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; @@ -42,7 +41,6 @@ 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; @@ -52,9 +50,10 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< public ChooserMultiProfilePagerAdapter( Context context, - ChooserGridAdapter adapter, + ImmutableList<TabConfig<ChooserGridAdapter>> tabs, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, int maxTargetsPerRow, @@ -62,31 +61,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< 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), + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, @@ -99,10 +74,10 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< private ChooserMultiProfilePagerAdapter( Context context, ChooserProfileAdapterBinder adapterBinder, - ImmutableList<ChooserGridAdapter> gridAdapters, + ImmutableList<TabConfig<ChooserGridAdapter>> tabs, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, @@ -110,7 +85,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< super( gridAdapter -> gridAdapter.getListAdapter(), adapterBinder, - gridAdapters, + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, @@ -137,7 +112,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< */ public void setIsCollapsed(boolean isCollapsed) { for (int i = 0, size = getItemCount(); i < size; i++) { - getAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); + getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); } } @@ -172,7 +147,7 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< /** Apply the specified {@code height} as the footer in each tab's adapter. */ public void setFooterHeightInEveryAdapter(int height) { for (int i = 0; i < getItemCount(); ++i) { - getAdapterForIndex(i).setFooterHeight(height); + getPageAdapterForIndex(i).setFooterHeight(height); } } diff --git a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java index 2d9be816..43785db3 100644 --- a/java/src/com/android/intentresolver/v2/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 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. @@ -13,14 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.v2; +package com.android.intentresolver.v2.profiles; import android.annotation.IntDef; import android.annotation.Nullable; import android.os.Trace; import android.os.UserHandle; +import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TabHost; +import android.widget.TextView; import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.ViewPager; @@ -28,33 +32,21 @@ 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.v2.emptystate.EmptyStateUiHelper; import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import java.util.HashSet; +import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; /** * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). - * <p> - * TODO: attempt to further restrict visibility/improve encapsulation in the methods we expose. - * <p> - * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" - * <p> - * 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. - * <p> - * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) - * <p> - * 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. * * @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 @@ -69,24 +61,11 @@ public class MultiProfilePagerAdapter< 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 {} + public @interface ProfileType {} private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder; @@ -99,22 +78,21 @@ public class MultiProfilePagerAdapter< private final UserHandle mCloneProfileUserHandle; private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet. - private Set<Integer> mLoadedPages; + private final Set<Integer> mLoadedPages; private int mCurrentPage; private OnProfileSelectedListener mOnProfileSelectedListener; protected MultiProfilePagerAdapter( Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor, AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder, - ImmutableList<SinglePageAdapterT> adapters, + ImmutableList<TabConfig<SinglePageAdapterT>> tabs, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, Supplier<ViewGroup> pageViewInflater, Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { - mCurrentPage = defaultProfile; mLoadedPages = new HashSet<>(); mWorkProfileUserHandle = workProfileUserHandle; mCloneProfileUserHandle = cloneProfileUserHandle; @@ -127,21 +105,190 @@ public class MultiProfilePagerAdapter< ImmutableList.Builder<ProfileDescriptor<PageViewT, SinglePageAdapterT>> items = new ImmutableList.Builder<>(); - for (SinglePageAdapterT adapter : adapters) { - items.add(createProfileDescriptor(adapter, containerBottomPaddingOverrideSupplier)); + for (TabConfig<SinglePageAdapterT> tab : tabs) { + // TODO: consider representing tabConfig in a different data structure that can ensure + // uniqueness of their profile assignments (while still respecting the client's + // requested tab order). + items.add( + createProfileDescriptor( + tab.mProfile, + tab.mTabLabel, + tab.mTabAccessibilityLabel, + tab.mTabTag, + tab.mPageAdapter, + containerBottomPaddingOverrideSupplier)); } mItems = items.build(); + + mCurrentPage = + hasPageForProfile(defaultProfile) ? getPageNumberForProfile(defaultProfile) : 0; } private ProfileDescriptor<PageViewT, SinglePageAdapterT> createProfileDescriptor( + @ProfileType int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, SinglePageAdapterT adapter, Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { return new ProfileDescriptor<>( - mPageViewInflater.get(), adapter, containerBottomPaddingOverrideSupplier); + profile, + tabLabel, + tabAccessibilityLabel, + tabTag, + mPageViewInflater.get(), + adapter, + containerBottomPaddingOverrideSupplier); + } + + private boolean hasPageForIndex(int pageIndex) { + return (pageIndex >= 0) && (pageIndex < getCount()); + } + + public final boolean hasPageForProfile(@ProfileType int profile) { + return hasPageForIndex(getPageNumberForProfile(profile)); + } + + private @ProfileType int getProfileForPageNumber(int position) { + if (hasPageForIndex(position)) { + return mItems.get(position).mProfile; + } + return -1; + } + + public int getPageNumberForProfile(@ProfileType int profile) { + for (int i = 0; i < mItems.size(); ++i) { + if (profile == mItems.get(i).mProfile) { + return i; + } + } + return -1; } - public void setOnProfileSelectedListener(OnProfileSelectedListener listener) { - mOnProfileSelectedListener = listener; + private ListAdapterT getListAdapterForPageNumber(int pageNumber) { + SinglePageAdapterT pageAdapter = getPageAdapterForIndex(pageNumber); + if (pageAdapter == null) { + return null; + } + return mListAdapterExtractor.apply(pageAdapter); + } + + private @ProfileType int getProfileForUserHandle(UserHandle userHandle) { + if (userHandle.equals(getCloneUserHandle())) { + // TODO: can we push this special case elsewhere -- e.g., when we check against each + // list adapter's user handle in the loop below, could we instead ask the list adapter + // whether it "represents" the queried user handle, and have the personal list adapter + // return true because it knows it's also associated with the clone profile? Or if we + // don't want to make modifications to the list adapter, maybe we could at least specify + // it in our per-page configuration data that we use to build our tabs/pages, and then + // maintain the relevant bookkeeping in our own ProfileDescriptor? + return PROFILE_PERSONAL; + } + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = getListAdapterForPageNumber(i); + if (listAdapter.getUserHandle().equals(userHandle)) { + return mItems.get(i).mProfile; + } + } + return -1; + } + + private int getPageNumberForUserHandle(UserHandle userHandle) { + return getPageNumberForProfile(getProfileForUserHandle(userHandle)); + } + + /** + * 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) { + return getListAdapterForPageNumber(getPageNumberForUserHandle(userHandle)); + } + + @Nullable + private ProfileDescriptor<PageViewT, SinglePageAdapterT> getDescriptorForUserHandle( + UserHandle userHandle) { + return getItem(getPageNumberForUserHandle(userHandle)); + } + + private int getPageNumberForTabTag(String tag) { + for (int i = 0; i < mItems.size(); ++i) { + if (Objects.equals(mItems.get(i).mTabTag, tag)) { + return i; + } + } + return -1; + } + + private void updateActiveTabStyle(TabHost tabHost) { + int currentTab = tabHost.getCurrentTab(); + + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + // TODO: can we avoid this downcast by pushing our knowledge of the intended view type + // somewhere else? + TextView tabText = (TextView) tabHost.getTabWidget().getChildAt(pageNumber); + tabText.setSelected(currentTab == pageNumber); + } + } + + public void setupProfileTabs( + LayoutInflater layoutInflater, + TabHost tabHost, + ViewPager viewPager, + int tabButtonLayoutResId, + int tabPageContentViewId, + Runnable onTabChangeListener, + OnProfileSelectedListener clientOnProfileSelectedListener) { + tabHost.setup(); + viewPager.setSaveEnabled(false); + + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = mItems.get(pageNumber); + Button profileButton = (Button) layoutInflater.inflate( + tabButtonLayoutResId, tabHost.getTabWidget(), false); + profileButton.setText(descriptor.mTabLabel); + profileButton.setContentDescription(descriptor.mTabAccessibilityLabel); + + TabHost.TabSpec profileTabSpec = tabHost.newTabSpec(descriptor.mTabTag) + .setContent(tabPageContentViewId) + .setIndicator(profileButton); + tabHost.addTab(profileTabSpec); + } + + tabHost.getTabWidget().setVisibility(View.VISIBLE); + + updateActiveTabStyle(tabHost); + + tabHost.setOnTabChangedListener(tabTag -> { + updateActiveTabStyle(tabHost); + + int pageNumber = getPageNumberForTabTag(tabTag); + if (pageNumber >= 0) { + viewPager.setCurrentItem(pageNumber); + } + onTabChangeListener.run(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(getCurrentPage()); + mOnProfileSelectedListener = + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { + tabHost.setCurrentTab(pageNumber); + clientOnProfileSelectedListener.onProfilePageSelected( + profileId, pageNumber); + } + + @Override + public void onProfilePageStateChanged(int state) { + clientOnProfileSelectedListener.onProfilePageStateChanged(state); + } + }; } /** @@ -159,7 +306,8 @@ public class MultiProfilePagerAdapter< mLoadedPages.add(position); } if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfileSelected(position); + mOnProfileSelectedListener.onProfilePageSelected( + getProfileForPageNumber(position), position); } } @@ -176,10 +324,7 @@ public class MultiProfilePagerAdapter< } public void clearInactiveProfileCache() { - if (mLoadedPages.size() == 1) { - return; - } - mLoadedPages.remove(1 - mCurrentPage); + forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber)); } @Override @@ -204,12 +349,8 @@ public class MultiProfilePagerAdapter< return mCurrentPage; } - public final @Profile int getActiveProfile() { - // TODO: here and elsewhere in this class, distinguish between a "profile ID" integer and - // its mapped "page index." When we support more than two profiles, this won't be a "stable - // mapping" -- some particular profile may not be represented by a "page," but the ones that - // are will be assigned contiguous page numbers that skip over the holes. - return getCurrentPage(); + public final @ProfileType int getActiveProfile() { + return getProfileForPageNumber(getCurrentPage()); } @VisibleForTesting @@ -241,7 +382,11 @@ public class MultiProfilePagerAdapter< * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li> * </ul> */ + @Nullable private ProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) { + if (!hasPageForIndex(pageIndex)) { + return null; + } return mItems.get(pageIndex); } @@ -263,7 +408,7 @@ public class MultiProfilePagerAdapter< } public final PageViewT getListViewForIndex(int index) { - return getItem(index).mView; + return getItem(index).getView(); } /** @@ -273,8 +418,11 @@ public class MultiProfilePagerAdapter< * depending on the adapter type. */ @VisibleForTesting - public final SinglePageAdapterT getAdapterForIndex(int index) { - return getItem(index).mAdapter; + public final SinglePageAdapterT getPageAdapterForIndex(int index) { + if (!hasPageForIndex(index)) { + return null; + } + return getItem(index).getAdapter(); } /** @@ -282,26 +430,7 @@ public class MultiProfilePagerAdapter< * 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; + mAdapterBinder.bind(getListViewForIndex(pageIndex), getPageAdapterForIndex(pageIndex)); } /** @@ -309,70 +438,35 @@ public class MultiProfilePagerAdapter< * 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())); + return getListAdapterForPageNumber(getCurrentPage()); } public final ListAdapterT getPersonalListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); - } - - /** @return whether our tab data contains a page for the specified {@code profile} ID. */ - public final boolean hasPageForProfile(@Profile int profile) { - // TODO: here and elsewhere in this class, distinguish between a "profile ID" integer and - // its mapped "page index." When we support more than two profiles, this won't be a "stable - // mapping" -- some particular profile may not be represented by a "page," but the ones that - // are will be assigned contiguous page numbers that skip over the holes. - return hasAdapterForIndex(profile); + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL)); } @Nullable public final ListAdapterT getWorkListAdapter() { - if (!hasAdapterForIndex(PROFILE_WORK)) { + if (!hasPageForProfile(PROFILE_WORK)) { return null; } - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_WORK)); } public final SinglePageAdapterT getCurrentRootAdapter() { - return getAdapterForIndex(getCurrentPage()); + return getPageAdapterForIndex(getCurrentPage()); } public final PageViewT getActiveAdapterView() { return getListViewForIndex(getCurrentPage()); } - @Nullable - public final PageViewT getInactiveAdapterView() { - if (getCount() < 2) { - return null; - } - return getListViewForIndex(1 - getCurrentPage()); - } - private boolean anyAdapterHasItems() { for (int i = 0; i < mItems.size(); ++i) { - ListAdapterT listAdapter = mListAdapterExtractor.apply(getAdapterForIndex(i)); + ListAdapterT listAdapter = getListAdapterForPageNumber(i); if (listAdapter.getCount() > 0) { return true; } @@ -381,13 +475,10 @@ public class MultiProfilePagerAdapter< } public void refreshPackagesInAllTabs() { - // TODO: handle all inactive profiles; for now we can only have at most one. It's unclear if - // this legacy logic really requires the active tab to be rebuilt first, or if we could just - // iterate over the tabs in arbitrary order. + // TODO: it's unclear if this legacy logic really requires the active tab to be rebuilt + // first, or if we could just iterate over the tabs in arbitrary order. getActiveListAdapter().handlePackagesChanged(); - if (getCount() > 1) { - getInactiveListAdapter().handlePackagesChanged(); - } + forEachInactivePage(page -> getListAdapterForPageNumber(page).handlePackagesChanged()); } /** @@ -445,9 +536,10 @@ public class MultiProfilePagerAdapter< // autolaunch conditions). boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded(); if (includePartialRebuildOfInactiveTabs) { - boolean rebuildInactiveCompleted = - rebuildInactiveTab(false) || getInactiveListAdapter().isTabLoaded(); - rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; + // Per legacy logic, avoid short-circuiting (TODO: why? possibly so that we *start* + // loading the inactive tabs even if we're still waiting on the active tab to finish?). + boolean completedRebuildingInactiveTabs = rebuildInactiveTabs(false); + rebuildCompleted = rebuildCompleted && completedRebuildingInactiveTabs; } return rebuildCompleted; } @@ -464,28 +556,43 @@ public class MultiProfilePagerAdapter< } /** - * Rebuilds the tab that is not currently visible to the user, if such one exists. - * <p>Returns {@code true} if rebuild has completed. + * Rebuilds any tabs that are not currently visible to the user. + * <p>Returns {@code true} if rebuild has completed in all inactive tabs. */ - private boolean rebuildInactiveTab(boolean doPostProcessing) { + private boolean rebuildInactiveTabs(boolean doPostProcessing) { Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); - if (getItemCount() == 1) { - Trace.endSection(); - return false; - } - boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing); + AtomicBoolean allRebuildsComplete = new AtomicBoolean(true); + forEachInactivePage(pageNumber -> { + // Evaluate the rebuild for every inactive page, even if we've already seen some adapter + // return an "incomplete" status (i.e., even if `allRebuildsComplete` is already false) + // and so we already know we'll end up returning false for the batch. + // TODO: any particular reason the per-page legacy logic was set up in this order, or + // could we possibly short-circuit the rebuild if the tab is already "loaded"? + ListAdapterT inactiveAdapter = getListAdapterForPageNumber(pageNumber); + boolean rebuildInactivePageCompleted = + rebuildTab(inactiveAdapter, doPostProcessing) || inactiveAdapter.isTabLoaded(); + if (!rebuildInactivePageCompleted) { + allRebuildsComplete.set(false); + } + }); Trace.endSection(); - return result; + return allRebuildsComplete.get(); } - private int userHandleToPageIndex(UserHandle userHandle) { - if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { - return PROFILE_PERSONAL; - } else { - return PROFILE_WORK; + protected void forEachPage(Consumer<Integer> pageNumberHandler) { + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + pageNumberHandler.accept(pageNumber); } } + protected void forEachInactivePage(Consumer<Integer> inactivePageNumberHandler) { + forEachPage(pageNumber -> { + if (pageNumber != getCurrentPage()) { + inactivePageNumberHandler.accept(pageNumber); + } + }); + } + protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { if (shouldSkipRebuild(activeListAdapter)) { activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); @@ -499,10 +606,6 @@ public class MultiProfilePagerAdapter< return emptyState != null && emptyState.shouldSkipDataRebuild(); } - private boolean hasAdapterForIndex(int pageIndex) { - return (pageIndex < getCount()); - } - /** * The empty state screens are shown according to their priority: * <ol> @@ -531,8 +634,8 @@ public class MultiProfilePagerAdapter< if (emptyState.getButtonClickListener() != null) { clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { - ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( - userHandleToPageIndex(listAdapter.getUserHandle())); + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = + getDescriptorForUserHandle(listAdapter.getUserHandle()); descriptor.mEmptyStateUi.showSpinner(); }); } @@ -540,24 +643,12 @@ public class MultiProfilePagerAdapter< 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(); - } - } - private void showEmptyState( ListAdapterT activeListAdapter, EmptyState emptyState, View.OnClickListener buttonOnClick) { - ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick); activeListAdapter.markTabLoaded(); } @@ -571,8 +662,8 @@ public class MultiProfilePagerAdapter< } public void showListView(ListAdapterT activeListAdapter) { - ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); descriptor.mEmptyStateUi.hide(); } @@ -581,11 +672,14 @@ public class MultiProfilePagerAdapter< * application state. */ public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() { - if (getCount() < 2) { - return false; - } - // TODO: check against *any* inactive adapter; for now we only have one. - return shouldShowEmptyStateScreen(getInactiveListAdapter()); + AtomicBoolean anyEmpty = new AtomicBoolean(false); + // TODO: The "inactive" condition is legacy logic. Could we simplify and ask "any"? + forEachInactivePage(pageNumber -> { + if (shouldShowEmptyStateScreen(getListAdapterForPageNumber(pageNumber))) { + anyEmpty.set(true); + } + }); + return anyEmpty.get(); } public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { @@ -595,72 +689,4 @@ public class MultiProfilePagerAdapter< && 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, - Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { - 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, - com.android.internal.R.id.resolver_list, - containerBottomPaddingOverrideSupplier); - } - - protected ViewGroup getEmptyStateView() { - return mEmptyStateView; - } - - private void setupContainerPadding() { - mEmptyStateUi.setupContainerPadding(); - } - } - - /** 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/profiles/OnProfileSelectedListener.java b/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java new file mode 100644 index 00000000..7bdbec4c --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/OnProfileSelectedListener.java @@ -0,0 +1,46 @@ +/* + * 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.profiles; + +import androidx.viewpager.widget.ViewPager; + +/** Listener interface for changes between the per-profile UI tabs. */ +public interface OnProfileSelectedListener { + /** + * Callback for when the user changes the active tab. + * <p>This callback is only called when the intent resolver or share sheet shows + * more than one profile. + * + * @param profileId the ID of the newly-selected profile, e.g. {@link #PROFILE_PERSONAL} + * if the personal profile tab was selected or {@link #PROFILE_WORK} if the + * work profile tab + * was selected. + */ + void onProfilePageSelected(@MultiProfilePagerAdapter.ProfileType int profileId, int pageNumber); + + + /** + * 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); +} diff --git a/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java b/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java new file mode 100644 index 00000000..3dbbd4d0 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/OnSwitchOnWorkSelectedListener.java @@ -0,0 +1,27 @@ +/* + * 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.profiles; + +/** + * 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/profiles/ProfileDescriptor.java b/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java new file mode 100644 index 00000000..e2e9c19d --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/ProfileDescriptor.java @@ -0,0 +1,82 @@ +/* + * 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.profiles; + +import android.view.ViewGroup; + +import com.android.intentresolver.v2.emptystate.EmptyStateUiHelper; + +import java.util.Optional; +import java.util.function.Supplier; + +// 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)? +class ProfileDescriptor<PageViewT, SinglePageAdapterT> { + final @MultiProfilePagerAdapter.ProfileType int mProfile; + final String mTabLabel; + final String mTabAccessibilityLabel; + final String mTabTag; + + 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; + + public SinglePageAdapterT getAdapter() { + return mAdapter; + } + + public PageViewT getView() { + return mView; + } + + private final PageViewT mView; + + ProfileDescriptor( + @MultiProfilePagerAdapter.ProfileType int forProfile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + ViewGroup rootView, + SinglePageAdapterT adapter, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + mProfile = forProfile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; + 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, + com.android.internal.R.id.resolver_list, + containerBottomPaddingOverrideSupplier); + } + + protected ViewGroup getEmptyStateView() { + return mEmptyStateView; + } + + public void setupContainerPadding() { + mEmptyStateUi.setupContainerPadding(); + } +} diff --git a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java index d96fd15a..e44cf8da 100644 --- a/java/src/com/android/intentresolver/v2/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/profiles/ResolverMultiProfilePagerAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 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.v2; +package com.android.intentresolver.v2.profiles; import android.content.Context; import android.os.UserHandle; @@ -27,7 +27,6 @@ import androidx.viewpager.widget.PagerAdapter; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter; import com.android.intentresolver.emptystate.EmptyStateProvider; -import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -37,40 +36,20 @@ 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, + ImmutableList<TabConfig<ResolverListAdapter>> tabs, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle) { this( context, - ImmutableList.of(personalAdapter, workAdapter), + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, @@ -81,17 +60,17 @@ public class ResolverMultiProfilePagerAdapter extends private ResolverMultiProfilePagerAdapter( Context context, - ImmutableList<ResolverListAdapter> listAdapters, + ImmutableList<TabConfig<ResolverListAdapter>> tabs, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { super( listAdapter -> listAdapter, (listView, bindAdapter) -> listView.setAdapter(bindAdapter), - listAdapters, + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, @@ -109,11 +88,13 @@ public class ResolverMultiProfilePagerAdapter extends /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */ public void clearCheckedItemsInInactiveProfiles() { - // TODO: apply to all inactive adapters; for now we just have the one. - ListView inactiveListView = getInactiveAdapterView(); - if (inactiveListView.getCheckedItemCount() > 0) { - inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); - } + // TODO: The "inactive" condition is legacy logic. Could we simplify and clear-all? + forEachInactivePage(pageNumber -> { + ListView inactiveListView = getListViewForIndex(pageNumber); + if (inactiveListView.getCheckedItemCount() > 0) { + inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); + } + }); } private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { diff --git a/java/src/com/android/intentresolver/v2/profiles/TabConfig.java b/java/src/com/android/intentresolver/v2/profiles/TabConfig.java new file mode 100644 index 00000000..994f8aff --- /dev/null +++ b/java/src/com/android/intentresolver/v2/profiles/TabConfig.java @@ -0,0 +1,38 @@ +/* + * 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.profiles; + +public class TabConfig<PageAdapterT> { + final @MultiProfilePagerAdapter.ProfileType int mProfile; + final String mTabLabel; + final String mTabAccessibilityLabel; + final String mTabTag; + final PageAdapterT mPageAdapter; + + public TabConfig( + @MultiProfilePagerAdapter.ProfileType int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + PageAdapterT pageAdapter) { + mProfile = profile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; + mPageAdapter = pageAdapter; + } +} diff --git a/java/src/com/android/intentresolver/v2/shared/model/Profile.kt b/java/src/com/android/intentresolver/v2/shared/model/Profile.kt new file mode 100644 index 00000000..6e37174c --- /dev/null +++ b/java/src/com/android/intentresolver/v2/shared/model/Profile.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.v2.shared.model + +import com.android.intentresolver.v2.shared.model.Profile.Type + +/** + * Associates [users][User] into a [Type] instance. + * + * This is a simple abstraction which combines a primary [user][User] with an optional + * [cloned apps][User.Role.CLONE] user. This encapsulates the cloned app user id, while still being + * available where needed. + */ +data class Profile( + val type: Type, + val primary: User, + /** + * An optional [User] of which contains second instances of some applications installed for the + * personal user. This value may only be supplied when creating the PERSONAL profile. + */ + val clone: User? = null +) { + + init { + clone?.apply { + require(primary.role == User.Role.PERSONAL) { + "clone is not supported for profile=${this@Profile.type} / primary=$primary" + } + require(role == User.Role.CLONE) { "clone is not a clone user ($this)" } + } + } + + enum class Type { + PERSONAL, + WORK, + PRIVATE + } +} diff --git a/java/src/com/android/intentresolver/v2/data/model/User.kt b/java/src/com/android/intentresolver/v2/shared/model/User.kt index 504b04c8..97db3280 100644 --- a/java/src/com/android/intentresolver/v2/data/model/User.kt +++ b/java/src/com/android/intentresolver/v2/shared/model/User.kt @@ -1,10 +1,25 @@ -package com.android.intentresolver.v2.data.model +/* + * 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.shared.model import android.annotation.UserIdInt import android.os.UserHandle -import com.android.intentresolver.v2.data.model.User.Type -import com.android.intentresolver.v2.data.model.User.Type.FULL -import com.android.intentresolver.v2.data.model.User.Type.PROFILE +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. diff --git a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java index 271c6f38..a1e1c7fa 100644 --- a/java/src/com/android/intentresolver/v2/ui/ActionTitle.java +++ b/java/src/com/android/intentresolver/v2/ui/ActionTitle.java @@ -21,7 +21,6 @@ import android.provider.MediaStore; import androidx.annotation.StringRes; import com.android.intentresolver.R; -import com.android.intentresolver.v2.ResolverActivity; /** * Provides a set of related resources for different use cases. diff --git a/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt new file mode 100644 index 00000000..1cd72ba5 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/ProfilePagerResources.kt @@ -0,0 +1,53 @@ +/* + * 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 + +import android.content.res.Resources +import com.android.intentresolver.inject.ApplicationOwned +import com.android.intentresolver.v2.data.repository.DevicePolicyResources +import com.android.intentresolver.v2.shared.model.Profile +import javax.inject.Inject +import com.android.intentresolver.R + +class ProfilePagerResources +@Inject +constructor( + @ApplicationOwned private val resources: Resources, + private val devicePolicyResources: DevicePolicyResources +) { + private val privateTabLabel by lazy { resources.getString(R.string.resolver_private_tab) } + + private val privateTabAccessibilityLabel by lazy { + resources.getString(R.string.resolver_private_tab_accessibility) + } + + fun profileTabLabel(profile: Profile.Type): String { + return when (profile) { + Profile.Type.PERSONAL -> devicePolicyResources.personalTabLabel + Profile.Type.WORK -> devicePolicyResources.workTabLabel + Profile.Type.PRIVATE -> privateTabLabel + } + } + + fun profileTabAccessibilityLabel(type: Profile.Type): String { + return when (type) { + Profile.Type.PERSONAL -> devicePolicyResources.personalTabAccessibilityLabel + Profile.Type.WORK -> devicePolicyResources.workTabAccessibilityLabel + Profile.Type.PRIVATE -> privateTabAccessibilityLabel + } + } +}
\ No newline at end of file diff --git a/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt new file mode 100644 index 00000000..2b01b5e7 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/ShareResultSender.kt @@ -0,0 +1,163 @@ +/* + * 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 + +import android.app.Activity +import android.app.compat.CompatChanges +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.service.chooser.ChooserResult +import android.service.chooser.ChooserResult.CHOOSER_RESULT_COPY +import android.service.chooser.ChooserResult.CHOOSER_RESULT_EDIT +import android.service.chooser.ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT +import android.service.chooser.ChooserResult.CHOOSER_RESULT_UNKNOWN +import android.service.chooser.ChooserResult.ResultType +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 dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ActivityContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "ShareResultSender" + +/** Reports the result of a share to another process across binder, via an [IntentSender] */ +interface ShareResultSender { + /** Reports user selection of an activity to launch from the provided choices. */ + fun onComponentSelected(component: ComponentName, directShare: Boolean) + + /** Reports user invocation of a built-in system action. See [ShareAction]. */ + fun onActionSelected(action: ShareAction) +} + +@AssistedFactory +interface ShareResultSenderFactory { + fun create(callerUid: Int, chosenComponentSender: IntentSender): ShareResultSenderImpl +} + +/** Dispatches Intents via IntentSender */ +fun interface IntentSenderDispatcher { + fun dispatchIntent(intentSender: IntentSender, intent: Intent) +} + +class ShareResultSenderImpl( + private val flags: ChooserServiceFlags, + @Main private val scope: CoroutineScope, + @Background val backgroundDispatcher: CoroutineDispatcher, + private val callerUid: Int, + private val resultSender: IntentSender, + private val intentDispatcher: IntentSenderDispatcher +) : ShareResultSender { + @AssistedInject + constructor( + @ActivityContext context: Context, + flags: ChooserServiceFlags, + @Main scope: CoroutineScope, + @Background backgroundDispatcher: CoroutineDispatcher, + @Assisted callerUid: Int, + @Assisted chosenComponentSender: IntentSender, + ) : this( + flags, + scope, + backgroundDispatcher, + callerUid, + chosenComponentSender, + IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) } + ) + + override fun onComponentSelected(component: ComponentName, directShare: Boolean) { + Log.i(TAG, "onComponentSelected: $component directShare=$directShare") + scope.launch { + val intent = createChosenComponentIntent(component, directShare) + intentDispatcher.dispatchIntent(resultSender, intent) + } + } + + override fun onActionSelected(action: ShareAction) { + Log.i(TAG, "onActionSelected: $action") + scope.launch { + if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + @ResultType val chosenAction = shareActionToChooserResult(action) + val intent: Intent = createSelectedActionIntent(chosenAction) + intentDispatcher.dispatchIntent(resultSender, intent) + } else { + Log.i(TAG, "Not sending SelectedAction") + } + } + } + + private suspend fun createChosenComponentIntent( + component: ComponentName, + direct: Boolean, + ): Intent { + // Add extra with component name for backwards compatibility. + val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) + + // Add ChooserResult value for Android V+ + if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + intent.putExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct) + ) + } else { + Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}") + } + return intent + } + + @ResultType + private fun shareActionToChooserResult(action: ShareAction) = + when (action) { + ShareAction.SYSTEM_COPY -> CHOOSER_RESULT_COPY + ShareAction.SYSTEM_EDIT -> CHOOSER_RESULT_EDIT + ShareAction.APPLICATION_DEFINED -> CHOOSER_RESULT_UNKNOWN + } + + private fun createSelectedActionIntent(@ResultType result: Int): Intent { + return Intent().putExtra(Intent.EXTRA_CHOOSER_RESULT, ChooserResult(result, null, false)) + } + + private suspend fun chooserResultSupported(uid: Int): Boolean { + return withContext(backgroundDispatcher) { + // background -> Binder call to system_server + CompatChanges.isChangeEnabled(ChooserResult.SEND_CHOOSER_RESULT, uid) + } + } +} + +private fun IntentSender.dispatchIntent(context: Context, intent: Intent) { + try { + sendIntent( + /* context = */ context, + /* code = */ Activity.RESULT_OK, + /* intent = */ intent, + /* onFinished = */ null, + /* handler = */ null + ) + } catch (e: IntentSender.SendIntentException) { + Log.e(TAG, "Failed to send intent to IntentSender", e) + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.kt new file mode 100644 index 00000000..07b17435 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ActivityModel.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.v2.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 java.util.Objects + +/** Contains Activity-scope information about the state when started. */ +data class ActivityModel( + /** The [Intent] received by the app */ + val intent: Intent, + /** The identifier for the sending app and user */ + val launchedFromUid: Int, + /** The package of the sending app */ + val launchedFromPackage: String, + /** The referrer as supplied to the activity. */ + val referrer: Uri? +) : Parcelable { + constructor( + source: Parcel + ) : this( + intent = source.requireParcelable(), + launchedFromUid = source.readInt(), + launchedFromPackage = requireNotNull(source.readString()), + referrer = source.readParcelable() + ) + + /** A package name from referrer, if it is an android-app URI */ + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + override fun describeContents() = 0 /* flags */ + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(intent, flags) + dest.writeInt(launchedFromUid) + dest.writeString(launchedFromPackage) + dest.writeParcelable(referrer, flags) + } + + companion object { + const val ACTIVITY_MODEL_KEY = "com.android.intentresolver.ACTIVITY_MODEL" + + @JvmField + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator<ActivityModel> { + override fun newArray(size: Int) = arrayOfNulls<ActivityModel>(size) + override fun createFromParcel(source: Parcel) = ActivityModel(source) + } + + @JvmStatic + fun createFrom(activity: Activity): ActivityModel { + return ActivityModel( + activity.intent, + activity.launchedFromUid, + Objects.requireNonNull<String>(activity.launchedFromPackage), + activity.referrer + ) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt new file mode 100644 index 00000000..4f3cf3cd --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt @@ -0,0 +1,209 @@ +/* + * 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.model + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_REFERRER +import android.content.IntentFilter +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +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 + +const val ANDROID_APP_SCHEME = "android-app" + +/** All of the things that are consumed from an incoming share Intent (+Extras). */ +data class ChooserRequest( + /** Required. Represents the content being sent. */ + val targetIntent: Intent, + + /** The action from [targetIntent] as retrieved with [Intent.getAction]. */ + val targetAction: String?, + + /** + * 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, + + /** The top-level content type as retrieved using [Intent.getType]. */ + val targetType: String?, + + /** The package name of the app which started the current activity instance. */ + val launchedFromPackage: String, + + /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */ + val title: CharSequence? = null, + + /** A String resource ID to load when [title] is null. */ + @get:StringRes val defaultTitleResource: Int = 0, + + /** + * 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?, + + /** + * Choices to exclude from results. + * + * Any resolved intents with a component in this list will be omitted before presentation. + */ + val filteredComponentNames: List<ComponentName> = emptyList(), + + /** + * App provided shortcut share intents (aka "direct share targets") + * + * Normally share shortcuts are published and consumed using + * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow + * apps to directly inject the same information. + * + * Historical note: This option was initially integrated with other results from the + * ChooserTargetService API (since deprecated and removed), hence the name and data format. + * These are more correctly called "Share Shortcuts" now. + */ + val callerChooserTargets: List<ChooserTarget> = emptyList(), + + /** + * Actions the user may perform. These are presented as separate affordances from the main list + * of choices. Selecting a choice is a terminal action which results in finishing. The item + * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate. + */ + val chooserActions: List<ChooserAction> = emptyList(), + + /** + * An action to start an Activity which for user updating of shared content. Selection is a + * terminal action, closing the current activity and launching the target of the action. + */ + val modifyShareAction: ChooserAction? = null, + + /** + * When false the host activity will be [finished][android.app.Activity.finish] when stopped. + */ + @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false, + + /** + * Intents which contain alternate representations of the content being shared. Any results from + * resolving these _alternate_ intents are included with the results of the primary intent as + * additional choices (e.g. share as image content vs. link to content). + */ + val additionalTargets: List<Intent> = emptyList(), + + /** + * Alternate [extras][Intent.getExtras] to substitute when launching a selected app. + * + * For a given app (by package name), the Bundle describes what parameters to substitute when + * that app is selected. + * + * // TODO: Map<String, Bundle> + */ + val replacementExtras: Bundle? = null, + + /** + * App-supplied choices to be presented first in the list. + * + * Custom labels and icons may be supplied using + * [LabeledIntent][android.content.pm.LabeledIntent]. + * + * Limit 2. + */ + val initialIntents: List<Intent> = emptyList(), + + /** + * Provides for callers to be notified when a component is selected. + * + * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the + * [ComponentName] of the item. + */ + val chosenComponentSender: IntentSender? = null, + + /** + * Provides a mechanism for callers to post-process a target when a selection is made. + * + * The received intent will contain: + * * **EXTRA_INTENT** The chosen target + * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target + * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a + * mechanism for the caller to return information. An updated intent to send must be included + * as [Intent.EXTRA_INTENT]. + */ + val refinementIntentSender: IntentSender? = null, + + /** + * Contains the text content to share supplied by the source app. + * + * TODO: Constrain length? + */ + val sharedText: CharSequence? = null, + + /** + * Supplied to + * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to + * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType] + * are considered for matching share shortcuts currently. + */ + val shareTargetFilter: IntentFilter? = null, + + /** A URI for additional content */ + val additionalContentUri: Uri? = null, + + /** Focused item index (from target intent's STREAM_EXTRA) */ + val focusedItemPosition: Int = 0, + + /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */ + val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE, + + /** + * Metadata to be shown to the user as a part of the sharesheet window. + * + * Specified by the [Intent.EXTRA_METADATA_TEXT] + */ + val metadataText: CharSequence? = null, +) { + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + fun getReferrerFillInIntent(): Intent { + return Intent().apply { + referrerPackage?.also { pkg -> + putExtra(EXTRA_REFERRER, Uri.parse("$ANDROID_APP_SCHEME://$pkg")) + } + } + } + + 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/v2/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt new file mode 100644 index 00000000..a4f74ca9 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ResolverRequest.kt @@ -0,0 +1,68 @@ +/* + * 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.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 + +/** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */ +data class ResolverRequest( + /** The intent to be resolved to a target. */ + val intent: Intent, + + /** + * Supplied by the system to indicate which profile should be selected by default. This is + * required since ResolverActivity may be launched as either the originating OR target user when + * resolving a cross profile intent. + * + * Valid values are: [PERSONAL][Profile.Type.PERSONAL] and [WORK][Profile.Type.WORK] and null + * when the intent is not a forwarded cross-profile intent. + */ + val selectedProfile: Profile.Type?, + + /** + * When handing a cross profile forwarded intent, this is the user which started the original + * intent. This is required to allow ResolverActivity to be launched as the target user under + * some conditions. + */ + val callingUser: UserHandle?, + + /** + * Indicates if resolving actions for a connected device which has audio capture capability + * (e.g. is a USB Microphone). + * + * When used to handle a connected device, ResolverActivity uses this signal to present a + * warning when a resolved application does not hold the RECORD_AUDIO permission. (If selected + * the app would be able to capture audio directly via the device, bypassing audio API + * permissions.) + */ + val isAudioCaptureDevice: Boolean = false, + + /** A list of a resolved activity targets. This list overrides normal intent resolution. */ + val resolutionList: List<ResolveInfo>? = null, + + /** A customized title for the resolver interface. */ + val title: String? = null, +) { + val isResolvingHome = intent.isHomeIntent() + + /** For compatibility with existing code shared between chooser/resolver. */ + val payloadIntents: List<Intent> = listOf(intent) +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt b/java/src/com/android/intentresolver/v2/ui/model/ShareAction.kt new file mode 100644 index 00000000..e13ef101 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ShareAction.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.v2.ui.model + +enum class ShareAction { + SYSTEM_COPY, + SYSTEM_EDIT, + APPLICATION_DEFINED +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt new file mode 100644 index 00000000..91eed408 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -0,0 +1,198 @@ +/* + * 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.content.ComponentName +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_CHOSEN_COMPONENT_INTENT_SENDER +import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS +import android.content.Intent.EXTRA_INITIAL_INTENTS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT +import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS +import android.content.Intent.EXTRA_TEXT +import android.content.Intent.EXTRA_TITLE +import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK +import android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT +import android.content.IntentFilter +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import com.android.intentresolver.ChooserActivity +import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.R +import com.android.intentresolver.inject.ChooserServiceFlags +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 + +private const val MAX_CHOOSER_ACTIONS = 5 +private const val MAX_INITIAL_INTENTS = 2 + +internal fun Intent.maybeAddSendActionFlags() = + ifMatch(Intent::hasSendAction) { + addFlags(FLAG_ACTIVITY_NEW_DOCUMENT) + addFlags(FLAG_ACTIVITY_MULTIPLE_TASK) + } + +fun readChooserRequest( + launch: ActivityModel, + flags: ChooserServiceFlags +): ValidationResult<ChooserRequest> { + val extras = launch.intent.extras ?: Bundle() + @Suppress("DEPRECATION") + return validateFrom(extras::get) { + val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() + + val isSendAction = targetIntent.hasSendAction() + + val additionalTargets = readAlternateIntents() ?: emptyList() + + val replacementExtras = optional(value<Bundle>(EXTRA_REPLACEMENT_EXTRAS)) + + val (customTitle, defaultTitleResource) = + if (isSendAction) { + ignored( + value<CharSequence>(EXTRA_TITLE), + "deprecated in P. You may wish to set a preview title by using EXTRA_TITLE " + + "property of the wrapped EXTRA_INTENT." + ) + null to R.string.chooseActivity + } else { + val custom = optional(value<CharSequence>(EXTRA_TITLE)) + custom to (custom?.let { 0 } ?: R.string.chooseActivity) + } + + val initialIntents = + optional(array<Intent>(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map { + it.maybeAddSendActionFlags() + } + ?: emptyList() + + val chosenComponentSender = + optional(value<IntentSender>(EXTRA_CHOOSER_RESULT_INTENT_SENDER)) + ?: optional(value<IntentSender>(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER)) + + val refinementIntentSender = + optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) + + val filteredComponents = + optional(array<ComponentName>(EXTRA_EXCLUDE_COMPONENTS)) ?: emptyList() + + @Suppress("DEPRECATION") + val callerChooserTargets = + optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS)) ?: emptyList() + + val retainInOnStop = + optional(value<Boolean>(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false + + val sharedText = optional(value<CharSequence>(EXTRA_TEXT)) + + val chooserActions = readChooserActions() ?: emptyList() + + val modifyShareAction = optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) + + val additionalContentUri: Uri? + val focusedItemPos: Int + if (isSendAction && flags.chooserPayloadToggling()) { + additionalContentUri = optional(value<Uri>(Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) + focusedItemPos = optional(value<Int>(Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 + } else { + additionalContentUri = null + focusedItemPos = 0 + } + + val contentTypeHint = + if (flags.chooserAlbumText()) { + when (optional(value<Int>(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { + Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM + else -> ContentTypeHint.NONE + } + } else { + ContentTypeHint.NONE + } + + val metadataText = + if (flags.enableSharesheetMetadataExtra()) { + optional(value<CharSequence>(EXTRA_METADATA_TEXT)) + } else { + null + } + + ChooserRequest( + targetIntent = targetIntent, + targetAction = targetIntent.action, + isSendActionTarget = isSendAction, + targetType = targetIntent.type, + launchedFromPackage = + requireNotNull(launch.launchedFromPackage) { + "launch.fromPackage was null, See Activity.getLaunchedFromPackage()" + }, + title = customTitle, + defaultTitleResource = defaultTitleResource, + referrer = launch.referrer, + filteredComponentNames = filteredComponents, + callerChooserTargets = callerChooserTargets, + chooserActions = chooserActions, + modifyShareAction = modifyShareAction, + shouldRetainInOnStop = retainInOnStop, + additionalTargets = additionalTargets, + replacementExtras = replacementExtras, + initialIntents = initialIntents, + chosenComponentSender = chosenComponentSender, + refinementIntentSender = refinementIntentSender, + sharedText = sharedText, + shareTargetFilter = targetIntent.toShareTargetFilter(), + additionalContentUri = additionalContentUri, + focusedItemPosition = focusedItemPos, + contentTypeHint = contentTypeHint, + metadataText = metadataText, + ) + } +} + +fun Validation.readAlternateIntents(): List<Intent>? = + optional(array<Intent>(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() } + +fun Validation.readChooserActions(): List<ChooserAction>? = + optional(array<ChooserAction>(EXTRA_CHOOSER_CUSTOM_ACTIONS)) + ?.filter { hasValidIcon(it) } + ?.take(MAX_CHOOSER_ACTIONS) + +private fun Intent.toShareTargetFilter(): IntentFilter? { + return type?.let { + IntentFilter().apply { + action?.also { addAction(it) } + addDataType(it) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt new file mode 100644 index 00000000..8ed2fa29 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.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.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/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.kt new file mode 100644 index 00000000..bbc376ea --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestReader.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.v2.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 + +const val EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER" +const val EXTRA_SELECTED_PROFILE = + "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE" +const val EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device" + +fun readResolverRequest(launch: ActivityModel): ValidationResult<ResolverRequest> { + @Suppress("DEPRECATION") + return validateFrom((launch.intent.extras ?: Bundle())::get) { + val callingUser = optional(value<UserHandle>(EXTRA_CALLING_USER)) + val selectedProfile = checkSelectedProfile() + val audioDevice = optional(value<Boolean>(EXTRA_IS_AUDIO_CAPTURE_DEVICE)) ?: false + ResolverRequest(launch.intent, selectedProfile, callingUser, audioDevice) + } +} + +private fun Validation.checkSelectedProfile(): Profile.Type? { + return when (val selected = optional(value<Int>(EXTRA_SELECTED_PROFILE))) { + null -> null + PROFILE_PERSONAL -> Profile.Type.PERSONAL + PROFILE_WORK -> Profile.Type.WORK + else -> + error( + EXTRA_SELECTED_PROFILE + + " has invalid value ($selected)." + + " Must be either ResolverActivity.PROFILE_PERSONAL ($PROFILE_PERSONAL)" + + " or ResolverActivity.PROFILE_WORK ($PROFILE_WORK)." + ) + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/Findings.kt b/java/src/com/android/intentresolver/v2/validation/Findings.kt index 9a3cc9c7..bdf2f00a 100644 --- a/java/src/com/android/intentresolver/v2/validation/Findings.kt +++ b/java/src/com/android/intentresolver/v2/validation/Findings.kt @@ -34,9 +34,13 @@ val Finding.logcatPriority get() = when (importance) { CRITICAL -> Log.ERROR - else -> Log.WARN + WARNING -> Log.WARN } +fun Finding.log(tag: String) { + Log.println(logcatPriority, tag, message) +} + private fun formatMessage(key: String? = null, msg: String) = buildString { key?.also { append("['$key']: ") } append(msg) @@ -52,18 +56,21 @@ data class IgnoredValue( get() = formatMessage(key, "Ignored. $reason") } -data class RequiredValueMissing( +data class NoValue( val key: String, + override val importance: Importance, val allowedType: KClass<*>, ) : Finding { - override val importance = CRITICAL - override val message: String get() = formatMessage( key, - "expected value of ${allowedType.simpleName}, " + "but no value was present" + if (importance == CRITICAL) { + "expected value of ${allowedType.simpleName}, " + "but no value was present" + } else { + "no ${allowedType.simpleName} value present" + } ) } diff --git a/java/src/com/android/intentresolver/v2/validation/Validation.kt b/java/src/com/android/intentresolver/v2/validation/Validation.kt index 46939602..6072ec9f 100644 --- a/java/src/com/android/intentresolver/v2/validation/Validation.kt +++ b/java/src/com/android/intentresolver/v2/validation/Validation.kt @@ -90,7 +90,7 @@ fun <T> validateFrom(source: (String) -> Any?, validate: Validation.() -> T): Va is InvalidResultError -> Invalid(validation.findings) // Some other exception was thrown from [validate], - else -> Invalid(findings = listOf(UncaughtException(it))) + else -> Invalid(error = UncaughtException(it)) } } ) @@ -107,8 +107,8 @@ private class ValidationImpl(val source: (String) -> Any?) : Validation { override fun <T> ignored(property: Validator<T>, reason: String) { val result = property.validate(source, WARNING) - if (result.value != null) { - // Note: Any findings about the value (result.findings) are ignored. + if (result is Valid) { + // Note: Any warnings about the value itself (result.findings) are ignored. findings += IgnoredValue(property.key, reason) } } @@ -117,8 +117,16 @@ private class ValidationImpl(val source: (String) -> Any?) : Validation { return runCatching { property.validate(source, importance) } .fold( onSuccess = { result -> - findings += result.findings - result.value + return when (result) { + is Valid -> { + findings += result.warnings + result.value + } + is Invalid -> { + findings += result.errors + null + } + } }, onFailure = { findings += UncaughtException(it, property.key) diff --git a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt index 092cabe8..f5c467dc 100644 --- a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt +++ b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt @@ -15,25 +15,12 @@ */ package com.android.intentresolver.v2.validation -import android.util.Log +sealed interface ValidationResult<T> -sealed interface ValidationResult<T> { - val value: T? - val findings: List<Finding> - - fun isSuccess() = value != null - - fun getOrThrow(): T = - checkNotNull(value) { "The result was invalid: " + findings.joinToString(separator = "\n") } - - fun <T> reportToLogcat(tag: String) { - findings.forEach { Log.println(it.logcatPriority, tag, it.toString()) } - } +data class Valid<T>(val value: T, val warnings: List<Finding> = emptyList()) : ValidationResult<T> { + constructor(value: T, warning: Finding) : this(value, listOf(warning)) } -data class Valid<T>(override val value: T?, override val findings: List<Finding> = emptyList()) : - ValidationResult<T> - -data class Invalid<T>(override val findings: List<Finding>) : ValidationResult<T> { - override val value: T? = null +data class Invalid<T>(val errors: List<Finding> = emptyList()) : ValidationResult<T> { + constructor(error: Finding) : this(listOf(error)) } diff --git a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt index 3cefeb15..050bd895 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/IntentOrUri.kt @@ -18,7 +18,8 @@ package com.android.intentresolver.v2.validation.types import android.content.Intent import android.net.Uri import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.RequiredValueMissing +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 @@ -40,12 +41,14 @@ class IntentOrUri(override val key: String) : Validator<Intent> { is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME)) // No value present. - null -> createResult(importance, RequiredValueMissing(key, 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 -> { - return createResult( - importance, + return Invalid( ValueIsWrongType( key, importance, diff --git a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt index c6c4abba..78adfd36 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/ParceledArray.kt @@ -15,8 +15,10 @@ */ package com.android.intentresolver.v2.validation.types +import android.content.Intent import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.RequiredValueMissing +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 @@ -37,8 +39,10 @@ class ParceledArray<T : Any>( return when (val value: Any? = source(key)) { // No value present. - null -> createResult(importance, RequiredValueMissing(key, 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>. @@ -54,8 +58,7 @@ class ParceledArray<T : Any>( // At least one incorrect element type found. else -> - createResult( - importance, + Invalid( WrongElementType( key, importance, @@ -69,8 +72,7 @@ class ParceledArray<T : Any>( // The value is not an Array at all. else -> - createResult( - importance, + Invalid( ValueIsWrongType( key, importance, diff --git a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt index 3287b84b..0105541d 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/SimpleValue.kt @@ -16,7 +16,8 @@ package com.android.intentresolver.v2.validation.types import com.android.intentresolver.v2.validation.Importance -import com.android.intentresolver.v2.validation.RequiredValueMissing +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 @@ -36,19 +37,21 @@ class SimpleValue<T : Any>( expected.isInstance(value) -> return Valid(expected.cast(value)) // No value is present. - value == null -> createResult(importance, RequiredValueMissing(key, 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 -> - createResult( - importance, + 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/v2/validation/types/Validators.kt index 4e6e5dff..70993b4d 100644 --- a/java/src/com/android/intentresolver/v2/validation/types/Validators.kt +++ b/java/src/com/android/intentresolver/v2/validation/types/Validators.kt @@ -15,13 +15,6 @@ */ package com.android.intentresolver.v2.validation.types -import com.android.intentresolver.v2.validation.Finding -import com.android.intentresolver.v2.validation.Importance -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.Valid -import com.android.intentresolver.v2.validation.ValidationResult import com.android.intentresolver.v2.validation.Validator inline fun <reified T : Any> value(key: String): Validator<T> { @@ -31,15 +24,3 @@ inline fun <reified T : Any> value(key: String): Validator<T> { inline fun <reified T : Any> array(key: String): Validator<List<T>> { return ParceledArray(key, T::class) } - -/** - * Convenience function to wrap a finding in an appropriate result type. - * - * An error [finding] is suppressed when [importance] == [WARNING] - */ -internal fun <T> createResult(importance: Importance, finding: Finding): ValidationResult<T> { - return when (importance) { - WARNING -> Valid(null, listOf(finding).filter { it.importance == WARNING }) - CRITICAL -> Invalid(listOf(finding)) - } -} diff --git a/java/src/com/android/intentresolver/widget/BadgeTextView.kt b/java/src/com/android/intentresolver/widget/BadgeTextView.kt new file mode 100644 index 00000000..b6cadd86 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/BadgeTextView.kt @@ -0,0 +1,88 @@ +package com.android.intentresolver.widget + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.TextView + +/** + * A TextView that supports a badge at the end of the text. If the text, when centered in the view, + * leaves enough room for the badge, the badge is just displayed at the end of the view. Otherwise, + * the necessary amount of space for the badge is reserved and the text gets centered in the + * remaining free space. + */ +class BadgeTextView : TextView { + constructor(context: Context) : this(context, null) + + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int + ) : this(context, attrs, defStyleAttr, 0) + + constructor( + context: Context?, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) { + super.setGravity(Gravity.CENTER) + defaultPaddingLeft = paddingLeft + defaultPaddingRight = paddingRight + } + + private var defaultPaddingLeft = 0 + private var defaultPaddingRight = 0 + + override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { + super.setPadding(left, top, right, bottom) + defaultPaddingLeft = paddingLeft + defaultPaddingRight = paddingRight + } + + override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) { + super.setPaddingRelative(start, top, end, bottom) + defaultPaddingLeft = paddingLeft + defaultPaddingRight = paddingRight + } + + /** Sets end-sided badge. */ + var badgeDrawable: Drawable? = null + set(value) { + if (field !== value) { + field = value + super.setBackground(value) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.setPadding(defaultPaddingLeft, paddingTop, defaultPaddingRight, paddingBottom) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val badge = badgeDrawable ?: return + if (badge.intrinsicWidth <= paddingEnd) return + var maxLineWidth = 0f + for (i in 0 until layout.lineCount) { + maxLineWidth = maxOf(maxLineWidth, layout.getLineWidth(i)) + } + val sideSpace = (measuredWidth - maxLineWidth) / 2 + if (sideSpace < badge.intrinsicWidth) { + super.setPaddingRelative( + paddingStart, + paddingTop, + paddingEnd + badge.intrinsicWidth - sideSpace.toInt(), + paddingBottom + ) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } + + override fun setBackground(background: Drawable?) { + badgeDrawable = null + super.setBackground(background) + } + + override fun setGravity(gravity: Int): Unit = error("Not supported") +} diff --git a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java index 4ea0681d..37bbc6ce 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -54,13 +54,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); private UsageStatsManager mUsm; - // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at - // onCreate and needs to see some non-negative value in the test. - @Override - public int getLaunchedFromUid() { - return 1234; - } - @Override public ChooserListAdapter createChooserListAdapter( Context context, @@ -93,7 +86,8 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW maxTargetsPerRow, userHandle, targetDataLoader, - null); + null, + mFeatureFlags); } @Override diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java index 32eabbed..d6ee706a 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserActivityOverrideData.java +++ b/tests/activity/src/com/android/intentresolver/v2/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; @@ -33,11 +32,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 @@ -52,15 +51,12 @@ 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; @@ -73,17 +69,15 @@ public class ChooserActivityOverrideData { 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); + resolverListController = mock(ChooserListController.class); + workResolverListController = mock(ChooserListController.class); alternateProfileSetting = 0; resources = null; annotatedUserHandles = AnnotatedUserHandles.newBuilder() @@ -94,7 +88,6 @@ public class ChooserActivityOverrideData { hasCrossProfileIntents = true; isQuietModeEnabled = false; myUserId = null; - packageManager = null; mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { @Override public boolean isQuietModeEnabled() { diff --git a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java index a7930f8a..07e6e7b4 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ChooserWrapperActivity.java @@ -23,7 +23,6 @@ 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; @@ -40,8 +39,6 @@ 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.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.TargetDataLoader; import com.android.intentresolver.shortcuts.ShortcutLoader; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -57,22 +54,13 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW private UsageStatsManager mUsm; @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setLogic(new TestChooserActivityLogic( - "ChooserWrapper", - () -> this, - this::onWorkProfileStatusUpdated, - () -> mTargetDataLoader, - this::onPreinitialization, - sOverrides)); - } - - // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at - // onCreate and needs to see some non-negative value in the test. - @Override - public int getLaunchedFromUid() { - return 1234; + protected final ChooserActivityLogic createActivityLogic() { + return new TestChooserActivityLogic( + "ChooserWrapper", + /* activity = */ this, + this::onWorkProfileStatusUpdated, + sOverrides.annotatedUserHandles, + sOverrides.mWorkProfileAvailability); } @Override @@ -86,11 +74,8 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW UserHandle userHandle, Intent targetIntent, Intent referrerFillInIntent, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { - PackageManager packageManager = - sOverrides.packageManager == null ? context.getPackageManager() - : sOverrides.packageManager; + int maxTargetsPerRow) { + return new ChooserListAdapter( context, payloadIntents, @@ -102,12 +87,13 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW targetIntent, referrerFillInIntent, this, - packageManager, + mPackageManager, getEventLog(), maxTargetsPerRow, userHandle, - targetDataLoader, - null); + mTargetDataLoader, + null, + mFeatureFlags); } @Override @@ -117,17 +103,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 @@ -178,14 +159,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; @@ -238,7 +211,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW @Override public UserHandle getCurrentUserHandle() { - return mMultiProfilePagerAdapter.getCurrentUserHandle(); + return mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); } @Override diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java index f0911833..993f1760 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverActivityTest.java @@ -25,8 +25,10 @@ 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; @@ -58,8 +60,12 @@ 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; @@ -74,6 +80,7 @@ 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 @@ -88,7 +95,10 @@ public class ResolverActivityTest { return clientIntent; } - @Rule + @Rule(order = 0) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 1) public ActivityTestRule<ResolverWrapperActivity> mActivityRule = new ActivityTestRule<>(ResolverWrapperActivity.class, false, false); diff --git a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java index 7ae58254..2e29be11 100644 --- a/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/v2/ResolverWrapperActivity.java @@ -60,22 +60,17 @@ public class ResolverWrapperActivity extends ResolverActivity { private final CountingIdlingResource mLabelIdlingResource = new CountingIdlingResource("LoadLabelTask"); - public ResolverWrapperActivity() { - super(/* isIntentPicker= */ true); - } - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setLogic(new TestResolverActivityLogic( + protected final ResolverActivityLogic createActivityLogic() { + return new TestResolverActivityLogic( "ResolverWrapper", - () -> this, + this, () -> { onWorkProfileStatusUpdated(); return Unit.INSTANCE; }, sOverrides - )); + ); } public CountingIdlingResource getLabelIdlingResource() { @@ -89,8 +84,7 @@ public class ResolverWrapperActivity extends ResolverActivity { Intent[] initialIntents, List<ResolveInfo> rList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { return new ResolverListAdapter( context, payloadIntents, @@ -102,7 +96,7 @@ public class ResolverWrapperActivity extends ResolverActivity { payloadIntents.get(0), // TODO: extract upstream this, userHandle, - new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource)); + new TargetDataLoaderWrapper(mTargetDataLoader, mLabelIdlingResource)); } @Override @@ -118,14 +112,11 @@ public class ResolverWrapperActivity extends ResolverActivity { } 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 @@ -154,14 +145,6 @@ 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(); } diff --git a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt index 198b9236..fe649819 100644 --- a/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt +++ b/tests/activity/src/com/android/intentresolver/v2/TestChooserActivityLogic.kt @@ -3,30 +3,23 @@ package com.android.intentresolver.v2 import androidx.activity.ComponentActivity import com.android.intentresolver.AnnotatedUserHandles import com.android.intentresolver.WorkProfileAvailabilityManager -import com.android.intentresolver.icons.TargetDataLoader /** Activity logic for use when testing [ChooserActivity]. */ class TestChooserActivityLogic( tag: String, - activityProvider: () -> ComponentActivity, + activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, - targetDataLoaderProvider: () -> TargetDataLoader, - onPreinitialization: () -> Unit, - private val overrideData: ChooserActivityOverrideData, + private val annotatedUserHandlesOverride: AnnotatedUserHandles?, + private val workProfileAvailabilityOverride: WorkProfileAvailabilityManager?, ) : ChooserActivityLogic( tag, - activityProvider, + activity, onWorkProfileStatusUpdated, - targetDataLoaderProvider, - onPreinitialization, ) { + override val annotatedUserHandles: AnnotatedUserHandles? + get() = annotatedUserHandlesOverride ?: super.annotatedUserHandles - override val annotatedUserHandles: AnnotatedUserHandles? by lazy { - overrideData.annotatedUserHandles - } - - override val workProfileAvailabilityManager: WorkProfileAvailabilityManager by lazy { - overrideData.mWorkProfileAvailability ?: super.workProfileAvailabilityManager - } + 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 index 7581043e..6826f23d 100644 --- a/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt +++ b/tests/activity/src/com/android/intentresolver/v2/TestResolverActivityLogic.kt @@ -7,10 +7,10 @@ import com.android.intentresolver.WorkProfileAvailabilityManager /** Activity logic for use when testing [ResolverActivity]. */ class TestResolverActivityLogic( tag: String, - activityProvider: () -> ComponentActivity, + activity: ComponentActivity, onWorkProfileStatusUpdated: () -> Unit, private val overrideData: ResolverWrapperActivity.OverrideData, -) : ResolverActivityLogic(tag, activityProvider, onWorkProfileStatusUpdated) { +) : ResolverActivityLogic(tag, activity, onWorkProfileStatusUpdated) { override val annotatedUserHandles: AnnotatedUserHandles? by lazy { overrideData.annotatedUserHandles diff --git a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java index 5245f655..b8113422 100644 --- a/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/v2/UnbundledChooserActivityTest.java @@ -128,14 +128,18 @@ import com.android.intentresolver.TestContentProvider; import com.android.intentresolver.TestPreviewImageLoader; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.contentpreview.ImageLoader; +import com.android.intentresolver.inject.PackageManagerModule; import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.logging.FakeEventLog; 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; import dagger.hilt.android.testing.HiltAndroidRule; import dagger.hilt.android.testing.HiltAndroidTest; @@ -150,12 +154,12 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; 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; @@ -165,16 +169,22 @@ 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; + +import javax.inject.Inject; /** * Instrumentation tests for ChooserActivity. * <p> * Legacy test suite migrated from framework CoreTests. */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") @RunWith(Parameterized.class) @HiltAndroidTest -@UninstallModules(ImageEditorModule.class) +@UninstallModules({ + AppPredictionModule.class, + ImageEditorModule.class, + PackageManagerModule.class +}) public class UnbundledChooserActivityTest { private static FakeEventLog getEventLog(ChooserWrapperActivity activity) { @@ -186,22 +196,12 @@ public class UnbundledChooserActivityTest { 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} - }); + @Parameters(name = "appPrediction={0}") + public static Iterable<?> parameters() { + return Arrays.asList( + /* appPredictionAvailable = */ true, + /* appPredictionAvailable = */ false + ); } private static final String TEST_MIME_TYPE = "application/TestType"; @@ -220,6 +220,25 @@ public class UnbundledChooserActivityTest { public ActivityTestRule<ChooserWrapperActivity> mActivityRule = new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); + @Inject + @ApplicationContext + Context mContext; + + /** An arbitrary pre-installed activity that handles this type of intent. */ + @BindValue + @ImageEditor + final Optional<ComponentName> mImageEditor = Optional.ofNullable( + ComponentName.unflattenFromString("com.google.android.apps.messaging/" + + ".ui.conversationlist.ShareIntentActivity")); + + /** Whether an AppPredictionService is available for use. */ + @BindValue + @AppPredictionAvailable + final boolean mAppPredictionAvailable; + + @BindValue + PackageManager mPackageManager; + @Before public void setUp() { // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the @@ -230,21 +249,18 @@ public class UnbundledChooserActivityTest { .adoptShellPermissionIdentity(); cleanOverrideData(); - mHiltAndroidRule.inject(); - } - private final Function<PackageManager, PackageManager> mPackageManagerOverride; + // Assign @Inject fields + mHiltAndroidRule.inject(); - /** An arbitrary pre-installed activity that handles this type of intent. */ - @BindValue - @ImageEditor - final Optional<ComponentName> mImageEditor = Optional.ofNullable( - ComponentName.unflattenFromString("com.google.android.apps.messaging/" - + ".ui.conversationlist.ShareIntentActivity")); + // Populate @BindValue dependencies using injected values. These fields contribute + // values to the dependency graph at activity launch time. This allows replacing + // arbitrary bindings per-test case if needed. + mPackageManager = mContext.getPackageManager(); + } - public UnbundledChooserActivityTest( - Function<PackageManager, PackageManager> packageManagerOverride) { - mPackageManagerOverride = packageManagerOverride; + public UnbundledChooserActivityTest(boolean appPredictionAvailable) { + mAppPredictionAvailable = appPredictionAvailable; } private void setDeviceConfigProperty( @@ -267,13 +283,18 @@ public class UnbundledChooserActivityTest { public void cleanOverrideData() { ChooserActivityOverrideData.getInstance().reset(); - ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride; setDeviceConfigProperty( SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, Boolean.toString(true)); } + private static PackageManager createFakePackageManager(ResolveInfo resolveInfo) { + PackageManager packageManager = mock(PackageManager.class); + when(packageManager.resolveActivity(any(Intent.class), any())).thenReturn(resolveInfo); + return packageManager; + } + @Test public void customTitle() throws InterruptedException { Intent viewIntent = createViewTextIntent(); @@ -955,8 +976,10 @@ public class UnbundledChooserActivityTest { throw exception; } RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getAdapter().getItemCount(), is(1)); - assertThat(recyclerView.getChildCount(), is(1)); + assertThat("recyclerView adapter item count", + recyclerView.getAdapter().getItemCount(), is(1)); + assertThat("recyclerView child view count", + recyclerView.getChildCount(), is(1)); View imageView = recyclerView.getChildAt(0); Rect rect = new Rect(); boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); @@ -2534,13 +2557,7 @@ public class UnbundledChooserActivityTest { 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); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); waitForIdle(); IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); @@ -2565,13 +2582,7 @@ public class UnbundledChooserActivityTest { 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()); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); waitForIdle(); IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); @@ -2596,13 +2607,8 @@ public class UnbundledChooserActivityTest { 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()); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); + mActivityRule.launchActivity(chooserIntent); waitForIdle(); @@ -2628,13 +2634,8 @@ public class UnbundledChooserActivityTest { 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()); + mPackageManager = createFakePackageManager(createFakeResolveInfo()); + mActivityRule.launchActivity(chooserIntent); waitForIdle(); @@ -2656,15 +2657,8 @@ public class UnbundledChooserActivityTest { // 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); + mPackageManager = createFakePackageManager(ResolverDataProvider.createResolveInfo(0, + UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE)); waitForIdle(); IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt index 888fc161..b352f360 100644 --- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt @@ -17,24 +17,41 @@ package com.android.intentresolver import android.content.Intent +import android.net.Uri import androidx.lifecycle.ViewModel 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.PreviewDataProvider +import com.android.intentresolver.contentpreview.PayloadToggleInteractor /** A test content preview model that supports image loader override. */ class TestContentPreviewViewModel( private val viewModel: BasePreviewViewModel, - private val imageLoader: ImageLoader? = null, + override val imageLoader: ImageLoader, ) : BasePreviewViewModel() { - override fun createOrReuseProvider( - targetIntent: Intent - ): PreviewDataProvider = viewModel.createOrReuseProvider(targetIntent) - override fun createOrReuseImageLoader(): ImageLoader = - imageLoader ?: viewModel.createOrReuseImageLoader() + 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 + ) + } companion object { fun wrap( @@ -47,10 +64,12 @@ class TestContentPreviewViewModel( modelClass: Class<T>, extras: CreationExtras ): T { + val wrapped = factory.create(modelClass, extras) as BasePreviewViewModel return TestContentPreviewViewModel( - factory.create(modelClass, extras) as BasePreviewViewModel, - imageLoader, - ) as T + wrapped, + imageLoader ?: wrapped.imageLoader, + ) + as T } } } diff --git a/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt b/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt new file mode 100644 index 00000000..73d9a084 --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/v2/data/repository/FakeUserRepository.kt @@ -0,0 +1,65 @@ +/* + * 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.data.repository + +import com.android.intentresolver.v2.shared.model.User +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** A simple repository which can be initialized from a list and updated. */ +class FakeUserRepository(userList: List<User>) : UserRepository { + internal data class UserState(val user: User, val available: Boolean) + + private val userState = MutableStateFlow(userList.map { UserState(it, available = true) }) + + // Expose a List<User> from List<UserState> + override val users = userState.map { userList -> userList.map { it.user } } + + fun addUser(user: User, available: Boolean) { + require(userState.value.none { it.user.id == user.id }) { + "A User with ${user.id} already exists!" + } + userState.update { it + UserState(user, available) } + } + + fun removeUser(user: User) { + require(userState.value.any { it.user.id == user.id }) { + "A User with ${user.id} does not exist!" + } + userState.update { it.filterNot { state -> state.user.id == user.id } } + } + + override val availability = + userState.map { userStateList -> userStateList.associate { it.user to it.available } } + + fun updateState(user: User, available: Boolean) { + userState.update { userStateList -> + userStateList.map { userState -> + if (userState.user.id == user.id) { + UserState(user, available) + } else { + userState + } + } + } + } + + override suspend fun requestState(user: User, available: Boolean) { + updateState(user, available) + } +} diff --git a/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt b/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.kt new file mode 100644 index 00000000..3878c39c --- /dev/null +++ b/tests/shared/src/com/android/intentresolver/v2/ext/ParcelableExt.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.v2.ext + +import android.os.Parcel +import android.os.Parcelable +import java.lang.reflect.Field + +inline fun <reified T : Parcelable> T.toParcelAndBack(): T { + val creator: Parcelable.Creator<out T> = getCreator() + val parcel = Parcel.obtain() + writeToParcel(parcel, 0) + parcel.setDataPosition(0) + return creator.createFromParcel(parcel) +} + +inline fun <reified T : Parcelable> getCreator(): Parcelable.Creator<out T> { + return getCreator(T::class.java) +} + +inline fun <reified T : Parcelable> getCreator(clazz: Class<out T>): Parcelable.Creator<out T> { + return try { + val field: Field = clazz.getDeclaredField("CREATOR") + @Suppress("UNCHECKED_CAST") + field.get(null) as Parcelable.Creator<T> + } catch (e: NoSuchFieldException) { + error("$clazz is a Parcelable without CREATOR") + } catch (e: IllegalAccessException) { + error("CREATOR in $clazz::class is not accessible") + } +} diff --git a/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt b/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt deleted file mode 100644 index 1ff0ce8e..00000000 --- a/tests/shared/src/com/android/intentresolver/v2/validation/ValidationResultSubject.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.android.intentresolver.v2.validation - -import com.google.common.truth.FailureMetadata -import com.google.common.truth.IterableSubject -import com.google.common.truth.Subject -import com.google.common.truth.Truth.assertAbout - -class ValidationResultSubject(metadata: FailureMetadata, private val actual: ValidationResult<*>?) : - Subject(metadata, actual) { - - fun isSuccess() = check("isSuccess()").that(actual?.isSuccess()).isTrue() - fun isFailure() = check("isSuccess()").that(actual?.isSuccess()).isFalse() - - fun value(): Subject = check("value").that(actual?.value) - - fun findings(): IterableSubject = check("findings").that(actual?.findings) - - companion object { - fun assertThat(input: ValidationResult<*>): ValidationResultSubject = - assertAbout(::ValidationResultSubject).that(input) - } -} diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp index a07af1a4..f8b80c72 100644 --- a/tests/unit/Android.bp +++ b/tests/unit/Android.bp @@ -52,6 +52,7 @@ android_test { "junit", "kotlinx_coroutines_test", "mockito-target-minus-junit4", + "platform-compat-test-rules", // PlatformCompatChangeRule "testables", // TestableContext/TestableResources "truth", "truth-java8-extension", diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt index 98c5e008..ca91c243 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt @@ -46,6 +46,8 @@ class ChooserListAdapterDataTest { private val immediateExecutor = TestExecutor(immediate = true) private val referrerFillInIntent = Intent().putExtra(Intent.EXTRA_REFERRER, "org.referrer.package") + private val featureFlags = + FakeFeatureFlagsImpl().apply { setFlag(Flags.FLAG_BESPOKE_LABEL_VIEW, false) } @Test fun test_twoTargetsWithNonOverlappingInitialIntent_threeTargetsInResolverAdapter() { @@ -97,6 +99,7 @@ class ChooserListAdapterDataTest { null, backgroundExecutor, immediateExecutor, + featureFlags, ) val doPostProcessing = true @@ -160,6 +163,7 @@ class ChooserListAdapterDataTest { null, backgroundExecutor, immediateExecutor, + featureFlags, ) val doPostProcessing = true diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt index cb043943..3c23ff26 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -33,6 +33,7 @@ import com.android.intentresolver.chooser.SelectableTargetInfo import com.android.intentresolver.chooser.TargetInfo import com.android.intentresolver.icons.TargetDataLoader import com.android.intentresolver.logging.EventLogImpl +import com.android.intentresolver.widget.BadgeTextView import com.android.internal.R import com.google.common.truth.Truth.assertThat import org.junit.Before @@ -57,6 +58,7 @@ class ChooserListAdapterTest { private val mEventLog = mock<EventLogImpl>() private val mTargetDataLoader = mock<TargetDataLoader>() private val mPackageChangeCallback = mock<ChooserListAdapter.PackageChangeCallback>() + private val featureFlags = FeatureFlagsImpl() private val testSubject by lazy { ChooserListAdapter( @@ -75,7 +77,8 @@ class ChooserListAdapterTest { 0, null, mTargetDataLoader, - mPackageChangeCallback + mPackageChangeCallback, + featureFlags, ) } @@ -216,10 +219,15 @@ class ChooserListAdapterTest { private fun createView(): View { val view = FrameLayout(context) - TextView(context).apply { - id = R.id.text1 - view.addView(this) - } + if (featureFlags.bespokeLabelView()) { + BadgeTextView(context) + } else { + TextView(context) + } + .apply { + id = R.id.text1 + view.addView(this) + } TextView(context).apply { id = R.id.text2 view.addView(this) diff --git a/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt b/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt index 90f6cf93..e721b5bb 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserRequestParametersTest.kt @@ -29,7 +29,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ChooserRequestParametersTest { - @Test fun testChooserActions() { val actionCount = 3 diff --git a/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt b/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt index 5e9cd98f..b25f4036 100644 --- a/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt +++ b/tests/unit/src/com/android/intentresolver/FakeResolverListCommunicator.kt @@ -27,8 +27,6 @@ class FakeResolverListCommunicator(private val layoutWithDefaults: Boolean = tru val sendVoiceCommandCount get() = sendVoiceCounter.get() - val updateProfileViewButtonCount - get() = updateProfileViewButtonCounter.get() override fun getReplacementIntent(activityInfo: ActivityInfo?, defIntent: Intent): Intent { return defIntent @@ -44,10 +42,6 @@ class FakeResolverListCommunicator(private val layoutWithDefaults: Boolean = tru sendVoiceCounter.incrementAndGet() } - override fun updateProfileViewButton() { - updateProfileViewButtonCounter.incrementAndGet() - } - override fun useLayoutWithDefault(): Boolean = layoutWithDefaults override fun shouldGetActivityMetadata(): Boolean = true diff --git a/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt index 61b9fd9c..2953a650 100644 --- a/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ResolverListAdapterTest.kt @@ -105,7 +105,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) } @@ -318,7 +317,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) backgroundExecutor.runUntilIdle() @@ -337,7 +335,6 @@ class ResolverListAdapterTest { } assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @@ -391,7 +388,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) backgroundExecutor.runUntilIdle() @@ -403,7 +399,6 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @@ -722,7 +717,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) backgroundExecutor.runUntilIdle() @@ -737,7 +731,6 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } @@ -794,7 +787,6 @@ class ResolverListAdapterTest { assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isFalse() assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(1) - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(0) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(0) backgroundExecutor.runUntilIdle() @@ -809,7 +801,6 @@ class ResolverListAdapterTest { assertThat(testSubject.filteredPosition).isLessThan(0) assertThat(testSubject.unfilteredResolveList).containsExactlyElementsIn(resolvedTargets) assertThat(testSubject.isTabLoaded).isTrue() - assertThat(resolverListCommunicator.updateProfileViewButtonCount).isEqualTo(1) assertThat(resolverListCommunicator.sendVoiceCommandCount).isEqualTo(1) assertThat(backgroundExecutor.pendingCommandCount).isEqualTo(0) } diff --git a/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt index a7574c12..b346bee5 100644 --- a/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ b/tests/unit/src/com/android/intentresolver/chooser/TargetInfoTest.kt @@ -24,9 +24,10 @@ import android.content.pm.ActivityInfo import android.content.pm.ResolveInfo import android.graphics.drawable.AnimatedVectorDrawable import android.os.UserHandle -import android.test.UiThreadTest +import androidx.test.annotation.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry 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 @@ -41,38 +42,37 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify class TargetInfoTest { - private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser() + private val PERSONAL_USER_HANDLE: UserHandle = + InstrumentationRegistry.getInstrumentation().getTargetContext().getUser() private val context = InstrumentationRegistry.getInstrumentation().getContext() @Before fun setup() { // SelectableTargetInfo reads DeviceConfig and needs a permission for that. - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() + InstrumentationRegistry.getInstrumentation() + .uiAutomation .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG") } @Test fun testNewEmptyTargetInfo() { val info = NotSelectableTargetInfo.newEmptyTargetInfo() - assertThat(info.isEmptyTargetInfo()).isTrue() - assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. + assertThat(info.isEmptyTargetInfo).isTrue() + assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model. assertThat(info.hasDisplayIcon()).isFalse() assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull() } - @UiThreadTest // AnimatedVectorDrawable needs to start from a thread with a Looper. + @UiThreadTest // AnimatedVectorDrawable needs to start from a thread with a Looper. @Test fun testNewPlaceholderTargetInfo() { val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context) assertThat(info.isPlaceHolderTargetInfo).isTrue() - assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model. + assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model. assertThat(info.hasDisplayIcon()).isTrue() assertThat(info.displayIconHolder.displayIcon) - .isInstanceOf(AnimatedVectorDrawable::class.java) + .isInstanceOf(AnimatedVectorDrawable::class.java) // TODO: assert that the animation is pre-started/running (IIUC this requires synchronizing // with some "render thread" per the `AnimatedVectorDrawable` docs). I believe this is // possible using `AnimatorTestRule` but I couldn't find any sample usage in Kotlin nor get @@ -82,34 +82,43 @@ class TargetInfoTest { @Test fun testNewSelectableTargetInfo() { val resolvedIntent = Intent() - val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( - resolvedIntent, - ResolverDataProvider.createResolveInfo(1, 0, PERSONAL_USER_HANDLE), - "label", - "extended info", - resolvedIntent - ) - val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") + val baseDisplayInfo = + DisplayResolveInfo.newDisplayResolveInfo( + resolvedIntent, + createResolveInfo(1, 0, PERSONAL_USER_HANDLE), + "label", + "extended info", + resolvedIntent + ) + val chooserTarget = + createChooserTarget( + "title", + 0.3f, + ResolverDataProvider.createComponentName(2), + "test_shortcut_id" + ) val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) - val appTarget = AppTarget( - AppTargetId("id"), - chooserTarget.componentName.packageName, - chooserTarget.componentName.className, - UserHandle.CURRENT) - - val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - baseDisplayInfo, - mock(), - resolvedIntent, - chooserTarget, - 0.1f, - shortcutInfo, - appTarget, - mock(), - ) + val appTarget = + AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT + ) + + val targetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + baseDisplayInfo, + mock(), + resolvedIntent, + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) assertThat(targetInfo.isSelectableTargetInfo).isTrue() - assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model. + assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model. assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(baseDisplayInfo) assertThat(targetInfo.chooserTargetComponentName).isEqualTo(chooserTarget.componentName) assertThat(targetInfo.directShareShortcutId).isEqualTo(shortcutInfo.id) @@ -121,33 +130,43 @@ class TargetInfoTest { @Test fun test_SelectableTargetInfo_componentName_no_source_info() { - val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") + val chooserTarget = + createChooserTarget( + "title", + 0.3f, + ResolverDataProvider.createComponentName(1), + "test_shortcut_id" + ) val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) - val appTarget = AppTarget( - AppTargetId("id"), - chooserTarget.componentName.packageName, - chooserTarget.componentName.className, - UserHandle.CURRENT) + val appTarget = + AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT + ) val pkgName = "org.package" val className = "MainActivity" - val backupResolveInfo = ResolveInfo().apply { - activityInfo = ActivityInfo().apply { - packageName = pkgName - name = className + val backupResolveInfo = + ResolveInfo().apply { + activityInfo = + ActivityInfo().apply { + packageName = pkgName + name = className + } } - } - - val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - null, - backupResolveInfo, - mock(), - chooserTarget, - 0.1f, - shortcutInfo, - appTarget, - mock(), - ) + + val targetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + null, + backupResolveInfo, + mock(), + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) assertThat(targetInfo.resolvedComponentName).isEqualTo(ComponentName(pkgName, className)) } @@ -156,32 +175,41 @@ class TargetInfoTest { val resolvedIntent = Intent("DONT_REFINE_ME") resolvedIntent.putExtra("resolvedIntent", true) - val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( - resolvedIntent, - ResolverDataProvider.createResolveInfo(1, 0), - "label", - "extended info", - resolvedIntent - ) - val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") + val baseDisplayInfo = + DisplayResolveInfo.newDisplayResolveInfo( + resolvedIntent, + createResolveInfo(1, 0), + "label", + "extended info", + resolvedIntent + ) + val chooserTarget = + createChooserTarget( + "title", + 0.3f, + ResolverDataProvider.createComponentName(2), + "test_shortcut_id" + ) val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) - val appTarget = AppTarget( - AppTargetId("id"), - chooserTarget.componentName.packageName, - chooserTarget.componentName.className, - UserHandle.CURRENT) - - val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - baseDisplayInfo, - mock(), - resolvedIntent, - chooserTarget, - 0.1f, - shortcutInfo, - appTarget, - mock(), - ) + val appTarget = + AppTarget( + AppTargetId("id"), + chooserTarget.componentName.packageName, + chooserTarget.componentName.className, + UserHandle.CURRENT + ) + + val targetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + baseDisplayInfo, + mock(), + resolvedIntent, + chooserTarget, + 0.1f, + shortcutInfo, + appTarget, + mock(), + ) val refinement = Intent("PROPOSED_REFINEMENT") assertThat(targetInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() @@ -193,18 +221,19 @@ class TargetInfoTest { intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") intent.setType("text/plain") - val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE) - - val targetInfo = DisplayResolveInfo.newDisplayResolveInfo( - intent, - resolveInfo, - "label", - "extended info", - intent - ) - assertThat(targetInfo.isDisplayResolveInfo()).isTrue() - assertThat(targetInfo.isMultiDisplayResolveInfo()).isFalse() - assertThat(targetInfo.isChooserTargetInfo()).isFalse() + val resolveInfo = createResolveInfo(3, 0, PERSONAL_USER_HANDLE) + + val targetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfo, + "label", + "extended info", + intent + ) + assertThat(targetInfo.isDisplayResolveInfo).isTrue() + assertThat(targetInfo.isMultiDisplayResolveInfo).isFalse() + assertThat(targetInfo.isChooserTargetInfo).isFalse() } @Test @@ -218,31 +247,30 @@ class TargetInfoTest { val extraMatch = Intent("REFINE_ME") extraMatch.putExtra("extraMatch", true) - val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ResolverDataProvider.createResolveInfo(3, 0), - "label", - "extended info", - originalIntent - ) + val originalInfo = + DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + createResolveInfo(3, 0), + "label", + "extended info", + originalIntent + ) originalInfo.addAlternateSourceIntent(mismatchedAlternate) originalInfo.addAlternateSourceIntent(targetAlternate) originalInfo.addAlternateSourceIntent(extraMatch) - 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)) // Note `DisplayResolveInfo` targets merge refinements directly into their `resolvedIntent`. - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("refinement", false)).isTrue() - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("targetAlternate", false)) - .isTrue() + assertThat(refinedResult.resolvedIntent?.getBooleanExtra("refinement", false)).isTrue() + assertThat(refinedResult.resolvedIntent?.getBooleanExtra("targetAlternate", false)).isTrue() // None of the other source intents got merged in (not even the later one that matched): - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("originalIntent", false)) - .isFalse() - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("mismatchedAlternate", false)) + assertThat(refinedResult.resolvedIntent?.getBooleanExtra("originalIntent", false)).isFalse() + assertThat(refinedResult.resolvedIntent?.getBooleanExtra("mismatchedAlternate", false)) .isFalse() - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("extraMatch", false)).isFalse() + assertThat(refinedResult.resolvedIntent?.getBooleanExtra("extraMatch", false)).isFalse() } @Test @@ -252,13 +280,14 @@ class TargetInfoTest { val mismatchedAlternate = Intent("DOESNT_MATCH") mismatchedAlternate.putExtra("mismatchedAlternate", true) - val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ResolverDataProvider.createResolveInfo(3, 0), - "label", - "extended info", - originalIntent - ) + val originalInfo = + DisplayResolveInfo.newDisplayResolveInfo( + originalIntent, + createResolveInfo(3, 0), + "label", + "extended info", + originalIntent + ) originalInfo.addAlternateSourceIntent(mismatchedAlternate) val refinement = Intent("PROPOSED_REFINEMENT") @@ -271,41 +300,50 @@ class TargetInfoTest { intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") intent.setType("text/plain") - val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE) - val firstTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( - intent, - resolveInfo, - "label 1", - "extended info 1", - intent - ) - val secondTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( - intent, - resolveInfo, - "label 2", - "extended info 2", - intent - ) - - val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - listOf(firstTargetInfo, secondTargetInfo)) - - assertThat(multiTargetInfo.isMultiDisplayResolveInfo()).isTrue() - assertThat(multiTargetInfo.isDisplayResolveInfo()).isTrue() // From legacy inheritance. - assertThat(multiTargetInfo.isChooserTargetInfo()).isFalse() - - assertThat(multiTargetInfo.getExtendedInfo()).isNull() - - assertThat(multiTargetInfo.getAllDisplayTargets()) - .containsExactly(firstTargetInfo, secondTargetInfo) + val packageName = "org.pkg.app" + val componentA = ComponentName(packageName, "org.pkg.app.ActivityA") + val componentB = ComponentName(packageName, "org.pkg.app.ActivityB") + val resolveInfoA = createResolveInfo(componentA, 0, PERSONAL_USER_HANDLE) + val resolveInfoB = createResolveInfo(componentB, 0, PERSONAL_USER_HANDLE) + val firstTargetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfoA, + "label 1", + "extended info 1", + intent + ) + val secondTargetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + intent, + resolveInfoB, + "label 2", + "extended info 2", + intent + ) + + val multiTargetInfo = + MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + listOf(firstTargetInfo, secondTargetInfo) + ) + + assertThat(multiTargetInfo.isMultiDisplayResolveInfo).isTrue() + assertThat(multiTargetInfo.isDisplayResolveInfo).isTrue() // From legacy inheritance. + assertThat(multiTargetInfo.isChooserTargetInfo).isFalse() + + assertThat(multiTargetInfo.extendedInfo).isNull() + + assertThat(multiTargetInfo.allDisplayTargets) + .containsExactly(firstTargetInfo, secondTargetInfo) assertThat(multiTargetInfo.hasSelected()).isFalse() - assertThat(multiTargetInfo.getSelectedTarget()).isNull() + assertThat(multiTargetInfo.selectedTarget).isNull() multiTargetInfo.setSelected(1) assertThat(multiTargetInfo.hasSelected()).isTrue() - assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo) + assertThat(multiTargetInfo.selectedTarget).isEqualTo(secondTargetInfo) + assertThat(multiTargetInfo.resolvedComponentName).isEqualTo(componentB) val refined = multiTargetInfo.tryToCloneWithAppliedRefinement(intent) assertThat(refined).isInstanceOf(MultiDisplayResolveInfo::class.java) @@ -321,37 +359,40 @@ class TargetInfoTest { val sendImage = Intent("SEND").apply { type = "image/png" } val sendUri = Intent("SEND").apply { type = "text/uri" } - val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0) - - val imageOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( - sendImage, - resolveInfo, - "Send Image", - "Sends only images", - sendImage - ) - - val textOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( - sendUri, - resolveInfo, - "Send Text", - "Sends only text", - sendUri - ) - - val imageOrTextTarget = DisplayResolveInfo.newDisplayResolveInfo( - sendImage, - resolveInfo, - "Send Image or Text", - "Sends images or text", - sendImage - ).apply { - addAlternateSourceIntent(sendUri) - } - - val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget) - ) + val resolveInfo = createResolveInfo(1, 0) + + val imageOnlyTarget = + DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + resolveInfo, + "Send Image", + "Sends only images", + sendImage + ) + + val textOnlyTarget = + DisplayResolveInfo.newDisplayResolveInfo( + sendUri, + resolveInfo, + "Send Text", + "Sends only text", + sendUri + ) + + val imageOrTextTarget = + DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + resolveInfo, + "Send Image or Text", + "Sends images or text", + sendImage + ) + .apply { addAlternateSourceIntent(sendUri) } + + val multiTargetInfo = + MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget) + ) multiTargetInfo.setSelected(0) assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOnlyTarget) @@ -370,22 +411,23 @@ class TargetInfoTest { fun testNewMultiDisplayResolveInfo_tryToCloneWithAppliedRefinement_delegatedToSelectedTarget() { val refined = Intent("SEND") val sendImage = Intent("SEND") - val targetOne = spy( - DisplayResolveInfo.newDisplayResolveInfo( - sendImage, - ResolverDataProvider.createResolveInfo(1, 0), - "Target One", - "Target One", - sendImage + val targetOne = + spy( + DisplayResolveInfo.newDisplayResolveInfo( + sendImage, + createResolveInfo(1, 0), + "Target One", + "Target One", + sendImage + ) ) - ) - val targetTwo = mock<DisplayResolveInfo> { - whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this) - } - - val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - listOf(targetOne, targetTwo) - ) + val targetTwo = + mock<DisplayResolveInfo> { + whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this) + } + + val multiTargetInfo = + MultiDisplayResolveInfo.newMultiDisplayResolveInfo(listOf(targetOne, targetTwo)) multiTargetInfo.setSelected(1) assertThat(multiTargetInfo.selectedTarget).isEqualTo(targetTwo) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 083ef180..c7c3c516 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -18,8 +18,11 @@ package com.android.intentresolver.contentpreview import android.content.Intent import android.net.Uri -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory +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.contentpreview.ChooserContentPreviewUi.ActionFactory import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow @@ -30,6 +33,7 @@ import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Rule import org.junit.Test import org.mockito.Mockito.never import org.mockito.Mockito.times @@ -40,29 +44,44 @@ class ChooserContentPreviewUiTest { private val previewData = mock<PreviewDataProvider>() private val headlineGenerator = mock<HeadlineGenerator>() private val imageLoader = TestPreviewImageLoader(emptyMap()) + private val testMetadataText: CharSequence = "Test metadata text" private val actionFactory = object : ActionFactory { override fun getCopyButtonRunnable(): Runnable? = null + override fun getEditButtonRunnable(): Runnable? = null + override fun createCustomActions(): List<ActionRow.Action> = emptyList() + override fun getModifyShareAction(): ActionRow.Action? = null + override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {} } private val transitionCallback = mock<ImagePreviewView.TransitionElementStatusCallback>() + @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() + + private fun createContentPreviewUi( + targetIntent: Intent, + isPayloadTogglingEnabled: Boolean = false + ) = + ChooserContentPreviewUi( + testScope, + previewData, + targetIntent, + imageLoader, + actionFactory, + transitionCallback, + headlineGenerator, + ContentTypeHint.NONE, + testMetadataText, + isPayloadTogglingEnabled, + ) @Test fun test_textPreviewType_useTextPreviewUi() { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT) - val testSubject = - ChooserContentPreviewUi( - testScope, - previewData, - Intent(Intent.ACTION_VIEW), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ) + val testSubject = createContentPreviewUi(targetIntent = Intent(Intent.ACTION_VIEW)) + assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) assertThat(testSubject.mContentPreviewUi).isInstanceOf(TextContentPreviewUi::class.java) @@ -72,16 +91,7 @@ class ChooserContentPreviewUiTest { @Test fun test_filePreviewType_useFilePreviewUi() { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE) - val testSubject = - ChooserContentPreviewUi( - testScope, - previewData, - Intent(Intent.ACTION_SEND), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ) + val testSubject = createContentPreviewUi(targetIntent = Intent(Intent.ACTION_SEND)) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.mContentPreviewUi).isInstanceOf(FileContentPreviewUi::class.java) @@ -97,14 +107,9 @@ class ChooserContentPreviewUiTest { .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = - ChooserContentPreviewUi( - testScope, - previewData, - Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") }, - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, + createContentPreviewUi( + targetIntent = + Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") } ) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) @@ -120,20 +125,30 @@ class ChooserContentPreviewUiTest { whenever(previewData.firstFileInfo) .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) - val testSubject = - ChooserContentPreviewUi( - testScope, - previewData, - Intent(Intent.ACTION_SEND), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ) + val testSubject = createContentPreviewUi(targetIntent = Intent(Intent.ACTION_SEND)) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java) verify(previewData, times(1)).imagePreviewFileInfoFlow verify(transitionCallback, never()).onAllTransitionElementsReady() } + + @Test + fun test_imagePayloadSelectionTypeWithEnabledFlag_usePayloadSelectionPreviewUi() { + // Event if we returned wrong type due to a bug, we should not use payload selection UI + val uri = Uri.parse("content://org.pkg.app/img.png") + whenever(previewData.previewType) + .thenReturn(ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION) + whenever(previewData.uriCount).thenReturn(2) + whenever(previewData.firstFileInfo) + .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) + whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) + val testSubject = + createContentPreviewUi( + targetIntent = Intent(Intent.ACTION_SEND), + isPayloadTogglingEnabled = true + ) + assertThat(testSubject.mContentPreviewUi) + .isInstanceOf(ShareouselContentPreviewUi::class.java) + } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt new file mode 100644 index 00000000..cd1c503a --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CursorUriReaderTest.kt @@ -0,0 +1,125 @@ +/* + * 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 d2d952ae..a540dfa2 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt @@ -35,6 +35,7 @@ import org.junit.runner.RunWith class FileContentPreviewUiTest { private val fileCount = 2 private val text = "Sharing 2 files" + private val testMetadataText: CharSequence = "Test metadata text" private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { override fun getEditButtonRunnable(): Runnable? = null @@ -54,10 +55,11 @@ class FileContentPreviewUiTest { fileCount, actionFactory, headlineGenerator, + testMetadataText, ) @Test - fun test_display_titleIsDisplayed() { + fun test_display_titleAndMetadataIsDisplayed() { val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -73,6 +75,8 @@ class FileContentPreviewUiTest { 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 @@ -85,15 +89,19 @@ class FileContentPreviewUiTest { 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?.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 7cc0b4b2..259ffdac 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.IdRes import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.android.intentresolver.R @@ -63,6 +64,7 @@ class FilesPlusTextContentPreviewUiTest { whenever(getVideosHeadline(anyInt())).thenReturn(HEADLINE_VIDEOS) whenever(getFilesHeadline(anyInt())).thenReturn(HEADLINE_FILES) } + private val testMetadataText: CharSequence = "Test metadata text" private val context get() = getInstrumentation().context @@ -74,6 +76,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_IMAGES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -85,6 +88,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -95,6 +99,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -106,6 +111,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -116,6 +122,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -128,6 +135,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -138,6 +146,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -149,6 +158,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -160,6 +170,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_IMAGES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -173,6 +184,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -184,6 +196,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_VIDEOS) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -197,6 +210,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -208,6 +222,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -221,6 +236,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -233,6 +249,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, testMetadataText) verifySharedText(previewView) } @@ -246,6 +263,7 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifyPreviewMetadata(headerParent, testMetadataText) verifySharedText(previewView) } @@ -262,7 +280,8 @@ class FilesPlusTextContentPreviewUiTest { actionFactory, imageLoader, DefaultMimeTypeClassifier, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -273,12 +292,14 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) verifyPreviewHeadline(previewView, HEADLINE_FILES) + verifyPreviewMetadata(previewView, 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) } @Test @@ -294,7 +315,8 @@ class FilesPlusTextContentPreviewUiTest { actionFactory, imageLoader, DefaultMimeTypeClassifier, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = @@ -306,6 +328,9 @@ class FilesPlusTextContentPreviewUiTest { assertWithMessage("External headline should not be inflated by default") .that(externalHeaderView.findViewById<View>(R.id.headline)) .isNull() + assertWithMessage("External metadata should not be inflated by default") + .that(externalHeaderView.findViewById<View>(R.id.metadata)) + .isNull() val previewView = testSubject.display( @@ -319,12 +344,14 @@ class FilesPlusTextContentPreviewUiTest { verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) verifyInternalHeadlineAbsence(previewView) verifyPreviewHeadline(externalHeaderView, HEADLINE_FILES) + verifyPreviewMetadata(externalHeaderView, 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) } private fun testLoadingHeadline( @@ -342,7 +369,8 @@ class FilesPlusTextContentPreviewUiTest { actionFactory, imageLoader, DefaultMimeTypeClassifier, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -371,7 +399,8 @@ class FilesPlusTextContentPreviewUiTest { actionFactory, imageLoader, DefaultMimeTypeClassifier, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = @@ -384,6 +413,10 @@ class FilesPlusTextContentPreviewUiTest { .that(externalHeaderView.findViewById<View>(R.id.headline)) .isNull() + assertWithMessage("External metadata should not be inflated by default") + .that(externalHeaderView.findViewById<View>(R.id.metadata)) + .isNull() + loadedFileMetadata?.let(testSubject::updatePreviewMetadata) return testSubject.display( context.resources, @@ -398,18 +431,27 @@ class FilesPlusTextContentPreviewUiTest { return mimeTypes.map { mimeType -> FileInfo.Builder(uri).withMimeType(mimeType).build() } } + private fun verifyTextViewText( + parentView: View?, + @IdRes textViewResId: Int, + expectedText: CharSequence, + ) { + assertThat(parentView).isNotNull() + val textView = parentView?.findViewById<TextView>(textViewResId) + assertThat(textView).isNotNull() + assertThat(textView?.text).isEqualTo(expectedText) + } + private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) { - assertThat(headerViewParent).isNotNull() - val headlineView = headerViewParent?.findViewById<TextView>(R.id.headline) - assertThat(headlineView).isNotNull() - assertThat(headlineView?.text).isEqualTo(expectedText) + verifyTextViewText(headerViewParent, R.id.headline, expectedText) + } + + private fun verifyPreviewMetadata(headerViewParent: View?, expectedText: CharSequence) { + verifyTextViewText(headerViewParent, R.id.metadata, expectedText) } private fun verifySharedText(previewView: ViewGroup?) { - assertThat(previewView).isNotNull() - val textContentView = previewView?.findViewById<TextView>(R.id.content_preview_text) - assertThat(textContentView).isNotNull() - assertThat(textContentView?.text).isEqualTo(SHARED_TEXT) + verifyTextViewText(previewView, R.id.content_preview_text, SHARED_TEXT) } private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt index a65280e5..dbc37b44 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt @@ -18,44 +18,73 @@ package com.android.intentresolver.contentpreview 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 -import com.google.common.truth.Truth.assertThat @RunWith(AndroidJUnit4::class) class HeadlineGeneratorImplTest { - @Test - fun testHeadlineGeneration() { - val generator = HeadlineGeneratorImpl( - InstrumentationRegistry.getInstrumentation().getTargetContext()) - val str = "Some string" - val url = "http://www.google.com" + private val generator = + HeadlineGeneratorImpl(InstrumentationRegistry.getInstrumentation().targetContext) + private val str = "Some string" + private val url = "http://www.google.com" + @Test + fun testTextHeadline() { assertThat(generator.getTextHeadline(str)).isEqualTo("Sharing text") assertThat(generator.getTextHeadline(url)).isEqualTo("Sharing link") + } + @Test + fun testImagesWIthTextHeadline() { assertThat(generator.getImagesWithTextHeadline(str, 1)).isEqualTo("Sharing image with text") assertThat(generator.getImagesWithTextHeadline(url, 1)).isEqualTo("Sharing image with link") - assertThat(generator.getImagesWithTextHeadline(str, 5)).isEqualTo("Sharing 5 images with text") - assertThat(generator.getImagesWithTextHeadline(url, 5)).isEqualTo("Sharing 5 images with link") + assertThat(generator.getImagesWithTextHeadline(str, 5)) + .isEqualTo("Sharing 5 images with text") + assertThat(generator.getImagesWithTextHeadline(url, 5)) + .isEqualTo("Sharing 5 images with link") + } + @Test + fun testVideosWithTextHeadline() { assertThat(generator.getVideosWithTextHeadline(str, 1)).isEqualTo("Sharing video with text") assertThat(generator.getVideosWithTextHeadline(url, 1)).isEqualTo("Sharing video with link") - assertThat(generator.getVideosWithTextHeadline(str, 5)).isEqualTo("Sharing 5 videos with text") - assertThat(generator.getVideosWithTextHeadline(url, 5)).isEqualTo("Sharing 5 videos with link") + assertThat(generator.getVideosWithTextHeadline(str, 5)) + .isEqualTo("Sharing 5 videos with text") + assertThat(generator.getVideosWithTextHeadline(url, 5)) + .isEqualTo("Sharing 5 videos with link") + } + @Test + fun testFilesWithTextHeadline() { assertThat(generator.getFilesWithTextHeadline(str, 1)).isEqualTo("Sharing file with text") assertThat(generator.getFilesWithTextHeadline(url, 1)).isEqualTo("Sharing file with link") - assertThat(generator.getFilesWithTextHeadline(str, 5)).isEqualTo("Sharing 5 files with text") - assertThat(generator.getFilesWithTextHeadline(url, 5)).isEqualTo("Sharing 5 files with link") + assertThat(generator.getFilesWithTextHeadline(str, 5)) + .isEqualTo("Sharing 5 files with text") + assertThat(generator.getFilesWithTextHeadline(url, 5)) + .isEqualTo("Sharing 5 files with link") + } + @Test + fun testImagesHeadline() { assertThat(generator.getImagesHeadline(1)).isEqualTo("Sharing image") assertThat(generator.getImagesHeadline(4)).isEqualTo("Sharing 4 images") + } + @Test + fun testVideosHeadline() { assertThat(generator.getVideosHeadline(1)).isEqualTo("Sharing video") assertThat(generator.getVideosHeadline(4)).isEqualTo("Sharing 4 videos") + } + @Test + fun testFilesHeadline() { assertThat(generator.getFilesHeadline(1)).isEqualTo("Sharing 1 file") assertThat(generator.getFilesHeadline(4)).isEqualTo("Sharing 4 files") } + + @Test + fun testAlbumHeadline() { + assertThat(generator.getAlbumHeadline()).isEqualTo("Sharing album") + } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt new file mode 100644 index 00000000..25c27468 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PayloadToggleInteractorTest.kt @@ -0,0 +1,162 @@ +/* + * 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 4a8c1392..babfaaf5 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -21,16 +21,20 @@ import android.content.Intent import android.database.MatrixCursor import android.media.MediaMetadata 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 import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.TestScope 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 @@ -42,12 +46,29 @@ class PreviewDataProviderTest { private val contentResolver = mock<ContentInterface>() private val mimeTypeClassifier = DefaultMimeTypeClassifier private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) + @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() + + private fun createDataProvider( + targetIntent: Intent, + scope: CoroutineScope = testScope, + additionalContentUri: Uri? = null, + resolver: ContentInterface = contentResolver, + typeClassifier: MimeTypeClassifier = mimeTypeClassifier, + isPayloadTogglingEnabled: Boolean = false + ) = + PreviewDataProvider( + scope, + targetIntent, + additionalContentUri, + resolver, + isPayloadTogglingEnabled, + typeClassifier, + ) @Test fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() { val targetIntent = Intent(Intent.ACTION_VIEW) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) verify(contentResolver, never()).getType(any()) @@ -62,8 +83,7 @@ class PreviewDataProviderTest { type = "text/plain" } whenever(contentResolver.getType(uri)).thenReturn("text/plain") - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -74,8 +94,7 @@ class PreviewDataProviderTest { @Test fun test_sendIntentWithoutUris_resolvesToTextPreviewUiSynchronously() { val targetIntent = Intent(Intent.ACTION_SEND).apply { type = "image/png" } - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) verify(contentResolver, never()).getType(any()) @@ -86,8 +105,7 @@ class PreviewDataProviderTest { val uri = Uri.parse("content://org.pkg.app/image.png") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("image/png") - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -101,8 +119,7 @@ class PreviewDataProviderTest { val uri = Uri.parse("content://org.pkg.app/paper.pdf") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("application/pdf") - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -120,8 +137,7 @@ class PreviewDataProviderTest { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure")) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -142,8 +158,7 @@ class PreviewDataProviderTest { .thenThrow(SecurityException("test failure")) whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) .thenThrow(SecurityException("test failure")) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -158,8 +173,7 @@ class PreviewDataProviderTest { val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getStreamTypes(uri, "*/*")) .thenReturn(arrayOf("application/pdf", "image/png")) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -195,8 +209,7 @@ class PreviewDataProviderTest { val cursor = MatrixCursor(columns).apply { addRow(values) } whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)).thenReturn(cursor) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(1) @@ -214,8 +227,7 @@ class PreviewDataProviderTest { val cursor = MatrixCursor(emptyArray()) whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)).thenReturn(cursor) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) verify(contentResolver, times(1)).query(uri, METADATA_COLUMNS, null, null) @@ -238,8 +250,7 @@ class PreviewDataProviderTest { } whenever(contentResolver.getType(uri1)).thenReturn("image/png") whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg") - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -265,8 +276,7 @@ class PreviewDataProviderTest { } ) } - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -293,8 +303,7 @@ class PreviewDataProviderTest { whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4") whenever(contentResolver.getStreamTypes(uri1, "*/*")).thenReturn(arrayOf("image/png")) whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -319,8 +328,7 @@ class PreviewDataProviderTest { } whenever(contentResolver.getType(uri1)).thenReturn("text/html") whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.uriCount).isEqualTo(2) @@ -350,8 +358,7 @@ class PreviewDataProviderTest { .thenReturn(arrayOf("text/html", "image/jpeg")) whenever(contentResolver.getStreamTypes(uri2, "*/*")) .thenReturn(arrayOf("application/pdf", "image/png")) - val testSubject = - PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier) + val testSubject = createDataProvider(targetIntent) val fileInfoListOne = testSubject.imagePreviewFileInfoFlow.toList() val fileInfoListTwo = testSubject.imagePreviewFileInfoFlow.toList() @@ -364,4 +371,74 @@ class PreviewDataProviderTest { verify(contentResolver, times(1)).getType(uri2) verify(contentResolver, times(1)).getStreamTypes(uri2, "*/*") } + + @Test + fun sendItemsWithAdditionalContentUri_showPayloadTogglingUi() { + val uri = Uri.parse("content://org.pkg.app/image.png") + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } + whenever(contentResolver.getType(uri)).thenReturn("image/png") + val testSubject = + createDataProvider( + targetIntent, + additionalContentUri = Uri.parse("content://org.pkg.app.extracontent"), + isPayloadTogglingEnabled = true, + ) + + assertThat(testSubject.previewType) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri) + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun sendItemsWithAdditionalContentUri_showImagePreviewUi() { + val uri = Uri.parse("content://org.pkg.app/image.png") + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } + whenever(contentResolver.getType(uri)).thenReturn("image/png") + val testSubject = + createDataProvider( + targetIntent, + additionalContentUri = Uri.parse("content://org.pkg.app.extracontent"), + ) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri) + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun sendItemsWithAdditionalContentUriWithSameAuthority_showImagePreviewUi() { + val uri = Uri.parse("content://org.pkg.app/image.png") + val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } + whenever(contentResolver.getType(uri)).thenReturn("image/png") + val testSubject = + createDataProvider( + targetIntent, + additionalContentUri = Uri.parse("content://org.pkg.app/extracontent"), + isPayloadTogglingEnabled = true, + ) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri) + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_nonSendIntentActionWithAdditionalContentUri_resolvesToTextPreviewUiSynchronously() { + val targetIntent = Intent(Intent.ACTION_VIEW) + val testSubject = + createDataProvider( + targetIntent, + additionalContentUri = Uri.parse("content://org.pkg.app/extracontent") + ) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) + verify(contentResolver, never()).getType(any()) + } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt new file mode 100644 index 00000000..1a59a930 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewViewModelTest.kt @@ -0,0 +1,81 @@ +/* + * 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/SelectionChangeCallbackTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt new file mode 100644 index 00000000..40f2ab26 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionChangeCallbackTest.kt @@ -0,0 +1,357 @@ +/* + * 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.app.PendingIntent +import android.content.ComponentName +import android.content.ContentInterface +import android.content.Intent +import android.content.Intent.ACTION_CHOOSER +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +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_TARGETS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_STREAM +import android.graphics.drawable.Icon +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 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.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 org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class SelectionChangeCallbackTest { + 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 + + @Test + fun testPayloadChangeCallbackContact() { + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + val u1 = createUri(1) + val u2 = createUri(2) + val targetIntent = + Intent(ACTION_SEND_MULTIPLE).apply { + val uris = + ArrayList<Uri>().apply { + add(u1) + add(u2) + } + putExtra(EXTRA_STREAM, uris) + type = "image/jpg" + } + testSubject.onSelectionChanged(targetIntent) + + val authorityCaptor = argumentCaptor<String>() + val methodCaptor = argumentCaptor<String>() + val argCaptor = argumentCaptor<String>() + val extraCaptor = argumentCaptor<Bundle>() + verify(contentResolver, times(1)) + .call( + capture(authorityCaptor), + capture(methodCaptor), + capture(argCaptor), + capture(extraCaptor) + ) + assertWithMessage("Wrong additional content provider authority") + .that(authorityCaptor.value) + .isEqualTo(uri.authority) + assertWithMessage("Wrong additional content provider #call() method name") + .that(methodCaptor.value) + .isEqualTo(ON_SELECTION_CHANGED) + assertWithMessage("Wrong additional content provider argument value") + .that(argCaptor.value) + .isEqualTo(uri.toString()) + val extraBundle = extraCaptor.value + 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) + .isNotNull() + requireNotNull(argChooserIntent) + assertWithMessage("#call() extras arg's Intent#EXTRA_INTENT should be a Chooser intent") + .that(argChooserIntent.action) + .isEqualTo(chooserIntent.action) + val argTargetIntent = argChooserIntent.getParcelableExtra(EXTRA_INTENT, Intent::class.java) + assertWithMessage( + "A chooser intent passed into #call() method should contain updated target intent" + ) + .that(argTargetIntent) + .isNotNull() + requireNotNull(argTargetIntent) + assertWithMessage("Incorrect target intent") + .that(argTargetIntent.action) + .isEqualTo(targetIntent.action) + assertWithMessage("Incorrect target intent") + .that(argTargetIntent.getParcelableArrayListExtra(EXTRA_STREAM, Uri::class.java)) + .containsExactly(u1, u2) + .inOrder() + } + + @Test + fun testPayloadChangeCallbackUpdatesCustomActions() { + val a1 = + ChooserAction.Builder( + Icon.createWithContentUri(createUri(10)), + "Action 1", + PendingIntent.getBroadcast( + context, + 1, + Intent("test"), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + val a2 = + ChooserAction.Builder( + Icon.createWithContentUri(createUri(11)), + "Action 2", + PendingIntent.getBroadcast( + context, + 1, + Intent("test"), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + 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 }) + .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() + } + + @Test + fun testPayloadChangeCallbackUpdatesReselectionAction() { + val modifyShare = + ChooserAction.Builder( + Icon.createWithContentUri(createUri(10)), + "Modify Share", + PendingIntent.getBroadcast( + context, + 1, + Intent("test"), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + 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) + .isEqualTo(modifyShare.icon) + assertWithMessage("Unexpected modify share action: wrong label") + .that(result.modifyShareAction?.label) + .isEqualTo(modifyShare.label) + + assertThat(result.customActions).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesAlternateIntents() { + val alternateIntents = + arrayOf( + Intent(ACTION_SEND_MULTIPLE).apply { + addCategory("test") + type = "" + } + ) + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + 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) + .hasSize(1) + assertWithMessage("Wrong alternate intent: action") + .that(result.alternateIntents?.get(0)?.action) + .isEqualTo(alternateIntents[0].action) + assertWithMessage("Wrong alternate intent: categories") + .that(result.alternateIntents?.get(0)?.categories) + .containsExactlyElementsIn(alternateIntents[0].categories) + assertWithMessage("Wrong alternate intent: mime type") + .that(result.alternateIntents?.get(0)?.type) + .isEqualTo(alternateIntents[0].type) + + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.callerTargets).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesCallerTargets() { + val t1 = + ChooserTarget( + "Target 1", + Icon.createWithContentUri(createUri(1)), + 0.99f, + ComponentName("org.pkg.app", ".ClassA"), + null + ) + val t2 = + ChooserTarget( + "Target 2", + Icon.createWithContentUri(createUri(1)), + 1f, + ComponentName("org.pkg.app", ".ClassB"), + null + ) + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn( + Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + 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) + .comparingElementsUsing( + Correspondence.from( + BinaryPredicate<ChooserTarget?, ChooserTarget> { actual, expected -> + expected.componentName == actual?.componentName && + expected.title == actual?.title && + expected.icon == actual?.icon && + expected.score == actual?.score + }, + "" + ) + ) + .containsExactly(t1, t2) + .inOrder() + + assertThat(result.customActions).isNull() + assertThat(result.modifyShareAction).isNull() + assertThat(result.alternateIntents).isNull() + assertThat(result.refinementIntentSender).isNull() + } + + @Test + fun testPayloadChangeCallbackUpdatesRefinementIntentSender() { + 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_REFINEMENT_INTENT_SENDER, broadcast.intentSender) + } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + 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() + } + + @Test + fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() { + whenever(contentResolver.call(any<String>(), any(), any(), any())) + .thenReturn( + Bundle().apply { + putParcelableArrayList(EXTRA_CHOOSER_CUSTOM_ACTIONS, ArrayList<ChooserAction>()) + putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, createUri(1)) + putParcelableArrayList(EXTRA_ALTERNATE_INTENTS, ArrayList<Intent>()) + putParcelableArrayList(EXTRA_CHOOSER_TARGETS, ArrayList<ChooserTarget>()) + putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, createUri(2)) + } + ) + + val testSubject = SelectionChangeCallback(uri, chooserIntent, contentResolver) + + 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() + } +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg.images/$id.png") diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt new file mode 100644 index 00000000..6ba18466 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/SelectionTrackerTest.kt @@ -0,0 +1,330 @@ +/* + * 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/TargetIntentModifierTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt new file mode 100644 index 00000000..b589f566 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/TargetIntentModifierTest.kt @@ -0,0 +1,77 @@ +/* + * 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.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_STREAM +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class TargetIntentModifierTest { + @Test + fun testIntentActionChange() { + val testSubject = TargetIntentModifier<Uri>(Intent(ACTION_SEND), { this }, { "image/png" }) + + val u1 = createUri(1) + val u2 = createUri(2) + testSubject.onSelectionChanged(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 -> + assertThat(intent.action).isEqualTo(ACTION_SEND) + assertThat(intent.getParcelableExtra(EXTRA_STREAM, Uri::class.java)).isEqualTo(u1) + } + } + + @Test + fun testMimeTypeChange() { + val testSubject = + TargetIntentModifier<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 -> + assertThat(intent.type).isEqualTo("image/png") + } + + testSubject.onSelectionChanged(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 + -> + assertThat(intent.type).isEqualTo("*/*") + } + + testSubject.onSelectionChanged(listOf(u1 to "image/png", u2 to null)).let { intent -> + assertThat(intent.type).isEqualTo("*/*") + } + } + + // TODO: test that the original intent's extras and flags remains the same +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg/$id") + +private data class Item(val uri: Uri, val mimeType: String?) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt index 35362401..1c96070c 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -22,6 +22,7 @@ import android.view.ViewGroup import android.widget.TextView 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 @@ -38,6 +39,7 @@ import org.junit.runner.RunWith class TextContentPreviewUiTest { private val text = "Shared Text" private val title = "Preview Title" + private val albumHeadline = "Album headline" private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) private val actionFactory = object : ChooserContentPreviewUi.ActionFactory { @@ -49,7 +51,11 @@ class TextContentPreviewUiTest { } private val imageLoader = mock<ImageLoader>() private val headlineGenerator = - mock<HeadlineGenerator> { whenever(getTextHeadline(text)).thenReturn(text) } + mock<HeadlineGenerator> { + whenever(getTextHeadline(text)).thenReturn(text) + whenever(getAlbumHeadline()).thenReturn(albumHeadline) + } + private val testMetadataText: CharSequence = "Test metadata text" private val context get() = InstrumentationRegistry.getInstrumentation().context @@ -59,10 +65,12 @@ class TextContentPreviewUiTest { testScope, text, title, + testMetadataText, /*previewThumbnail=*/ null, actionFactory, imageLoader, headlineGenerator, + ContentTypeHint.NONE, ) @Test @@ -82,6 +90,9 @@ class TextContentPreviewUiTest { 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).isNotNull() + assertThat(metadataView?.text).isEqualTo(testMetadataText) } @Test @@ -94,15 +105,55 @@ class TextContentPreviewUiTest { 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 albumSubject = + TextContentPreviewUi( + testScope, + text, + title, + testMetadataText, + /*previewThumbnail=*/ null, + actionFactory, + imageLoader, + headlineGenerator, + ContentTypeHint.ALBUM, + ) + + val previewView = + albumSubject.display( + context.resources, + layoutInflater, + gridLayout, + /*headlineViewParent=*/ null + ) + + assertThat(previewView).isNotNull() + val headlineView = previewView?.findViewById<TextView>(R.id.headline) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(albumHeadline) + val metadataView = previewView?.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 7e07e0ca..faeaf133 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -21,13 +21,14 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +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 +import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow @@ -60,6 +61,7 @@ class UnifiedContentPreviewUiTest { whenever(getVideosHeadline(anyInt())).thenReturn(VIDEO_HEADLINE) whenever(getFilesHeadline(anyInt())).thenReturn(FILES_HEADLINE) } + private val testMetadataText: CharSequence = "Test metadata text" private val context get() = getInstrumentation().context @@ -69,6 +71,7 @@ class UnifiedContentPreviewUiTest { testLoadingHeadline("image/*", files = null) { previewView -> verify(headlineGenerator, times(1)).getImagesHeadline(2) verifyPreviewHeadline(previewView, IMAGE_HEADLINE) + verifyPreviewMetadata(previewView, testMetadataText) } } @@ -77,6 +80,7 @@ class UnifiedContentPreviewUiTest { testLoadingExternalHeadline("image/*", files = null) { externalHeaderView -> verify(headlineGenerator, times(1)).getImagesHeadline(2) verifyPreviewHeadline(externalHeaderView, IMAGE_HEADLINE) + verifyPreviewMetadata(externalHeaderView, testMetadataText) } } @@ -85,6 +89,7 @@ class UnifiedContentPreviewUiTest { testLoadingHeadline("video/*", files = null) { previewView -> verify(headlineGenerator, times(1)).getVideosHeadline(2) verifyPreviewHeadline(previewView, VIDEO_HEADLINE) + verifyPreviewMetadata(previewView, testMetadataText) } } @@ -93,6 +98,7 @@ class UnifiedContentPreviewUiTest { testLoadingExternalHeadline("video/*", files = null) { externalHeaderView -> verify(headlineGenerator, times(1)).getVideosHeadline(2) verifyPreviewHeadline(externalHeaderView, VIDEO_HEADLINE) + verifyPreviewMetadata(externalHeaderView, testMetadataText) } } @@ -101,6 +107,7 @@ class UnifiedContentPreviewUiTest { testLoadingHeadline("application/pdf", files = null) { previewView -> verify(headlineGenerator, times(1)).getFilesHeadline(2) verifyPreviewHeadline(previewView, FILES_HEADLINE) + verifyPreviewMetadata(previewView, testMetadataText) } } @@ -109,6 +116,7 @@ class UnifiedContentPreviewUiTest { testLoadingExternalHeadline("application/pdf", files = null) { externalHeaderView -> verify(headlineGenerator, times(1)).getFilesHeadline(2) verifyPreviewHeadline(externalHeaderView, FILES_HEADLINE) + verifyPreviewMetadata(externalHeaderView, testMetadataText) } } @@ -117,6 +125,7 @@ class UnifiedContentPreviewUiTest { testLoadingHeadline("*/*", files = null) { previewView -> verify(headlineGenerator, times(1)).getFilesHeadline(2) verifyPreviewHeadline(previewView, FILES_HEADLINE) + verifyPreviewMetadata(previewView, testMetadataText) } } @@ -125,6 +134,7 @@ class UnifiedContentPreviewUiTest { testLoadingExternalHeadline("*/*", files = null) { externalHeader -> verify(headlineGenerator, times(1)).getFilesHeadline(2) verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + verifyPreviewMetadata(externalHeader, testMetadataText) } } @@ -262,7 +272,8 @@ class UnifiedContentPreviewUiTest { }, files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker }, /*itemCount=*/ 2, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -302,7 +313,8 @@ class UnifiedContentPreviewUiTest { }, files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker }, /*itemCount=*/ 2, - headlineGenerator + headlineGenerator, + testMetadataText, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = @@ -326,15 +338,28 @@ class UnifiedContentPreviewUiTest { emptySourceFlow.tryEmit(endMarker) verifyInternalHeadlineAbsence(previewView) + verifyInternalMetadataAbsence(previewView) verificationBlock(externalHeaderView) } } + private fun verifyTextViewText( + viewParent: View?, + @IdRes textViewResId: Int, + expectedText: CharSequence, + ) { + assertThat(viewParent).isNotNull() + val textView = viewParent?.findViewById<TextView>(textViewResId) + assertThat(textView).isNotNull() + assertThat(textView?.text).isEqualTo(expectedText) + } + private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) { - Truth.assertThat(headerViewParent).isNotNull() - val headlineView = headerViewParent?.findViewById<TextView>(R.id.headline) - Truth.assertThat(headlineView).isNotNull() - Truth.assertThat(headlineView?.text).isEqualTo(expectedText) + verifyTextViewText(headerViewParent, R.id.headline, expectedText) + } + + private fun verifyPreviewMetadata(headerViewParent: View?, expectedText: CharSequence) { + verifyTextViewText(headerViewParent, R.id.metadata, expectedText) } private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { @@ -345,4 +370,12 @@ class UnifiedContentPreviewUiTest { .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 new file mode 100644 index 00000000..f7bf33fd --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/UriMetadataReaderTest.kt @@ -0,0 +1,100 @@ +/* + * 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.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 + +class UriMetadataReaderTest { + private val uri = Uri.parse("content://org.pkg.app/item") + private val contentResolver = mock<ContentInterface>() + + @Test + fun testImageUri() { + val mimeType = "image/png" + whenever(contentResolver.getType(uri)).thenReturn(mimeType) + val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + + testSubject.getMetadata(uri).let { fileInfo -> + assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) + assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType) + assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(uri) + } + } + + @Test + fun testFileUriWithImageTypeSupport() { + val mimeType = "application/pdf" + val imageType = "image/png" + whenever(contentResolver.getType(uri)).thenReturn(mimeType) + whenever(contentResolver.getStreamTypes(eq(uri), any())).thenReturn(arrayOf(imageType)) + val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + + testSubject.getMetadata(uri).let { fileInfo -> + assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) + assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType) + assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(uri) + } + } + + @Test + fun testFileUriWithThumbnailSupport() { + val mimeType = "application/pdf" + whenever(contentResolver.getType(uri)).thenReturn(mimeType) + val columns = arrayOf(DocumentsContract.Document.COLUMN_FLAGS) + whenever(contentResolver.query(eq(uri), eq(columns), anyOrNull(), anyOrNull())) + .thenReturn( + MatrixCursor(columns).apply { + addRow(arrayOf(DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL)) + } + ) + val testSubject = UriMetadataReader(contentResolver, DefaultMimeTypeClassifier) + + testSubject.getMetadata(uri).let { fileInfo -> + assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) + assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType) + assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(uri) + } + } + + @Test + fun testFileUriWithPreviewUri() { + val mimeType = "application/pdf" + val previewUri = uri.buildUpon().appendQueryParameter("preview", null).build() + whenever(contentResolver.getType(uri)).thenReturn(mimeType) + 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) + + testSubject.getMetadata(uri).let { fileInfo -> + assertWithMessage("Wrong uri").that(fileInfo.uri).isEqualTo(uri) + assertWithMessage("Wrong mime type").that(fileInfo.mimeType).isEqualTo(mimeType) + assertWithMessage("Wrong preview URI").that(fileInfo.previewUri).isEqualTo(previewUri) + } + } +} diff --git a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index 43d0df79..4eeae872 100644 --- a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -36,6 +36,7 @@ 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 import kotlinx.coroutines.test.TestCoroutineScheduler @@ -395,6 +396,47 @@ class ShortcutLoaderTest { } @Test + fun test_nullIntentFilterNoAppAppPredictorResults_returnEmptyResult() = + scope.runTest { + val shortcutManager = mock<ShortcutManager>() + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + UserHandle.of(0), + isPersonalProfile = true, + targetIntentFilter = null, + dispatcher, + callback + ) + + testSubject.updateAppTargets(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() + verify(appPredictor, times(1)) + .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) + appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) + + verify(shortcutManager, never()).getShareTargets(any()) + val resultCaptor = argumentCaptor<ShortcutLoader.Result>() + verify(callback, times(1)).accept(capture(resultCaptor)) + + val result = resultCaptor.value + assertWithMessage("A ShortcutManager result is expected") + .that(result.isFromAppPredictor) + .isFalse() + assertArrayEquals( + "Wrong input app targets in the result", + appTargets, + result.appTargets + ) + assertWithMessage("An empty result is expected").that(result.shortcutsByApp).isEmpty() + } + + @Test fun test_workProfileNotRunning_doNotCallServices() { testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) } diff --git a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt index b3486bb1..95e4c377 100644 --- a/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/ChooserActionFactoryTest.kt @@ -31,6 +31,8 @@ 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 @@ -45,7 +47,9 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.eq -import org.mockito.Mockito +import org.mockito.Mockito.eq +import org.mockito.Mockito.times +import org.mockito.Mockito.verify @RunWith(AndroidJUnit4::class) class ChooserActionFactoryTest { @@ -94,7 +98,7 @@ class ChooserActionFactoryTest { // click it customActions[0].onClicked.run() - Mockito.verify(logger).logCustomActionSelected(eq(0)) + 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)) @@ -114,7 +118,7 @@ class ChooserActionFactoryTest { 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).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)) @@ -134,17 +138,19 @@ class ChooserActionFactoryTest { } val testSubject = ChooserActionFactory( - context, - chooserRequest.targetIntent, - chooserRequest.referrerPackageName, - chooserRequest.chooserActions, - chooserRequest.modifyShareAction, - Optional.empty(), - logger, - {}, - { null }, - mock(), - {}, + /* 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() } @@ -160,23 +166,25 @@ class ChooserActionFactoryTest { } val testSubject = ChooserActionFactory( - context, - chooserRequest.targetIntent, - chooserRequest.referrerPackageName, - chooserRequest.chooserActions, - chooserRequest.modifyShareAction, - Optional.empty(), - logger, - {}, - { null }, - mock(), - {}, + /* 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 sendActionWithText_nonNullCopyRunnable() { + fun sendActionWithTextCopyRunnable() { val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") } val chooserRequest = @@ -184,21 +192,29 @@ class ChooserActionFactoryTest { whenever(this.targetIntent).thenReturn(targetIntent) whenever(chooserActions).thenReturn(ImmutableList.of()) } + + val resultSender = mock<ShareResultSender>() val testSubject = ChooserActionFactory( - context, - chooserRequest.targetIntent, - chooserRequest.referrerPackageName, - chooserRequest.chooserActions, - chooserRequest.modifyShareAction, - Optional.empty(), - logger, - {}, - { null }, - mock(), - {}, + /* 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 { @@ -228,17 +244,19 @@ class ChooserActionFactoryTest { } return ChooserActionFactory( - context, - chooserRequest.targetIntent, - chooserRequest.referrerPackageName, - chooserRequest.chooserActions, - chooserRequest.modifyShareAction, - Optional.empty(), - logger, - {}, - { null }, - mock(), - resultConsumer + /* 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 new file mode 100644 index 00000000..ec2b807d --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ChooserMutableActionFactoryTest.kt @@ -0,0 +1,139 @@ +/* + * 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/ProfileAvailabilityTest.kt b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt new file mode 100644 index 00000000..b4df058c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileAvailabilityTest.kt @@ -0,0 +1,78 @@ +/* + * 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.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.google.common.truth.Truth.assertThat +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) +class ProfileAvailabilityTest { + private val personalUser = User(0, User.Role.PERSONAL) + private val workUser = User(10, User.Role.WORK) + + private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser) + private val workProfile = Profile(Profile.Type.WORK, workUser) + + private val repository = FakeUserRepository(listOf(personalUser, workUser)) + private val interactor = UserInteractor(repository, launchedAs = personalUser.handle) + + @Test + fun testProfileAvailable() = runTest { + val availability = ProfileAvailability(backgroundScope, interactor) + runCurrent() + + assertThat(availability.isAvailable(personalProfile)).isTrue() + assertThat(availability.isAvailable(workProfile)).isTrue() + + availability.requestQuietModeState(workProfile, true) + runCurrent() + + assertThat(availability.isAvailable(workProfile)).isFalse() + + availability.requestQuietModeState(workProfile, false) + runCurrent() + + assertThat(availability.isAvailable(workProfile)).isTrue() + } + + @Test + fun waitingToEnableProfile() = runTest { + val availability = ProfileAvailability(backgroundScope, interactor) + runCurrent() + + availability.requestQuietModeState(workProfile, true) + assertThat(availability.waitingToEnableProfile).isFalse() + runCurrent() + + 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/v2/ProfileHelperTest.kt new file mode 100644 index 00000000..9cbbfcd8 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ProfileHelperTest.kt @@ -0,0 +1,290 @@ +/* + * 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 com.android.intentresolver.Flags.FLAG_ENABLE_PRIVATE_PROFILE +import com.android.intentresolver.inject.FakeChooserServiceFlags +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.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* + +import org.junit.Test + +class ProfileHelperTest { + + private val personalUser = User(0, User.Role.PERSONAL) + private val cloneUser = User(10, User.Role.CLONE) + + private val personalProfile = Profile(Profile.Type.PERSONAL, personalUser) + private val personalWithCloneProfile = Profile(Profile.Type.PERSONAL, personalUser, cloneUser) + + private val workUser = User(11, User.Role.WORK) + private val workProfile = Profile(Profile.Type.WORK, workUser) + + 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 fun assertProfiles( + helper: ProfileHelper, + personalProfile: Profile, + workProfile: 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() + } + + 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() + } + + 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() + } + } + + + @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) + + assertProfiles(helper, personalProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalUser.handle)) + .isEqualTo(personalProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + 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) + + assertProfiles(helper, personalWithCloneProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isFalse() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalUser.handle)).isEqualTo(personalUser.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + 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) + + assertProfiles(helper, personalWithCloneProfile) + + assertThat(helper.isLaunchedAsCloneProfile).isTrue() + assertThat(helper.launchedAsProfileType).isEqualTo(Profile.Type.PERSONAL) + assertThat(helper.getQueryIntentsHandle(personalWithCloneProfile.primary.handle)) + .isEqualTo(personalWithCloneProfile.clone?.handle) + assertThat(helper.tabOwnerUserHandleForLaunch) + .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) + + + 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) + assertThat(helper.getQueryIntentsHandle(workUser.handle)) + .isEqualTo(workProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + 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) + + 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) + assertThat(helper.getQueryIntentsHandle(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) + + 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) + assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) + .isEqualTo(privateProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } + + @Test + 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) + + + 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) + assertThat(helper.getQueryIntentsHandle(privateProfile.primary.handle)) + .isEqualTo(privateProfile.primary.handle) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(privateProfile.primary.handle) + } + + @Test + fun launchedByPersonal_withPrivate_privateDisabled() = runTest { + flags.setFlag(FLAG_ENABLE_PRIVATE_PROFILE, false) + + 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) + + 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) + assertThat(helper.tabOwnerUserHandleForLaunch).isEqualTo(personalProfile.primary.handle) + } +}
\ No newline at end of file diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.kt new file mode 100644 index 00000000..d10ea8d0 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/data/repository/FakeUserRepositoryTest.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. + */ + +package com.android.intentresolver.v2.data.repository + +import com.android.intentresolver.v2.coroutines.collectLastValue +import com.android.intentresolver.v2.shared.model.User +import com.google.common.truth.Truth.assertThat +import kotlin.random.Random +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FakeUserRepositoryTest { + private val baseId = Random.nextInt(1000, 2000) + + private val personalUser = User(id = baseId, role = User.Role.PERSONAL) + private val cloneUser = User(id = baseId + 1, role = User.Role.CLONE) + private val workUser = User(id = baseId + 2, role = User.Role.WORK) + private val privateUser = User(id = baseId + 3, role = User.Role.PRIVATE) + + @Test + fun init() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser, privateUser)) + + val users by collectLastValue(repo.users) + assertThat(users).containsExactly(personalUser, workUser, privateUser) + } + + @Test + fun addUser() = runTest { + val repo = FakeUserRepository(emptyList()) + + val users by collectLastValue(repo.users) + assertThat(users).isEmpty() + + repo.addUser(personalUser, true) + assertThat(users).containsExactly(personalUser) + + repo.addUser(workUser, false) + assertThat(users).containsExactly(personalUser, workUser) + } + + @Test + fun removeUser() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser)) + + val users by collectLastValue(repo.users) + repo.removeUser(workUser) + assertThat(users).containsExactly(personalUser) + + repo.removeUser(personalUser) + assertThat(users).isEmpty() + } + + @Test + fun isAvailable_defaultValue() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser)) + + val available by collectLastValue(repo.availability) + + repo.requestState(workUser, false) + assertThat(available!![workUser]).isFalse() + + repo.requestState(workUser, true) + assertThat(available!![workUser]).isTrue() + } + + @Test + fun isAvailable() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser)) + + val available by collectLastValue(repo.availability) + assertThat(available!![workUser]).isTrue() + + repo.requestState(workUser, false) + assertThat(available!![workUser]).isFalse() + + repo.requestState(workUser, true) + assertThat(available!![workUser]).isTrue() + } + + @Test + fun isAvailable_addRemove() = runTest { + val repo = FakeUserRepository(listOf(personalUser, workUser)) + + val available by collectLastValue(repo.availability) + assertThat(available!![workUser]).isTrue() + + repo.removeUser(workUser) + assertThat(available!![workUser]).isNull() + + repo.addUser(workUser, true) + assertThat(available!![workUser]).isTrue() + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt index 4f514db5..16e8c9bb 100644 --- a/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/data/repository/UserRepositoryImplTest.kt @@ -8,10 +8,10 @@ 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.data.model.User -import com.android.intentresolver.v2.data.model.User.Role 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.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage @@ -34,10 +34,7 @@ internal class UserRepositoryImplTest { assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() assertThat(users) - .containsExactly( - userState.primaryUserHandle, - User(userState.primaryUserHandle.identifier, Role.PERSONAL) - ) + .containsExactly(User(userState.primaryUserHandle.identifier, Role.PERSONAL)) } @Test @@ -46,10 +43,10 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users!!.values.filter { it.role.type == User.Type.PROFILE }).isEmpty() + assertThat(users!!.filter { it.role.type == User.Type.PROFILE }).isEmpty() val profile = userState.createProfile(ProfileType.WORK) - assertThat(users).containsEntry(profile, User(profile.identifier, Role.WORK)) + assertThat(users).contains(User(profile.identifier, Role.WORK)) } @Test @@ -59,40 +56,60 @@ internal class UserRepositoryImplTest { assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() val work = userState.createProfile(ProfileType.WORK) - assertThat(users).containsEntry(work, User(work.identifier, Role.WORK)) + assertThat(users).contains(User(work.identifier, Role.WORK)) userState.removeProfile(work) - assertThat(users).doesNotContainEntry(work, User(work.identifier, Role.WORK)) + assertThat(users).doesNotContain(User(work.identifier, Role.WORK)) } @Test fun isAvailable() = runTest { val repo = createUserRepository(userManager) val work = userState.createProfile(ProfileType.WORK) + val workUser = User(work.identifier, Role.WORK) - val available by collectLastValue(repo.isAvailable(work)) - assertThat(available).isTrue() + val available by collectLastValue(repo.availability) + assertThat(available?.get(workUser)).isTrue() userState.setQuietMode(work, true) - assertThat(available).isFalse() + assertThat(available?.get(workUser)).isFalse() userState.setQuietMode(work, false) - assertThat(available).isTrue() + assertThat(available?.get(workUser)).isTrue() + } + + @Test + fun onHandleAvailabilityChange_userStateMaintained() = runTest { + val repo = createUserRepository(userManager) + val private = userState.createProfile(ProfileType.PRIVATE) + val privateUser = User(private.identifier, Role.PRIVATE) + + val users by collectLastValue(repo.users) + + repo.requestState(privateUser, false) + repo.requestState(privateUser, true) + + assertWithMessage("users.size") + .that(users?.size ?: 0).isEqualTo(2) // personal + private + + assertWithMessage("No duplicate IDs") + .that(users?.count { it.id == private.identifier }).isEqualTo(1) } @Test fun requestState() = runTest { val repo = createUserRepository(userManager) val work = userState.createProfile(ProfileType.WORK) + val workUser = User(work.identifier, Role.WORK) - val available by collectLastValue(repo.isAvailable(work)) - assertThat(available).isTrue() + val available by collectLastValue(repo.availability) + assertThat(available?.get(workUser)).isTrue() - repo.requestState(work, false) - assertThat(available).isFalse() + repo.requestState(workUser, false) + assertThat(available?.get(workUser)).isFalse() - repo.requestState(work, true) - assertThat(available).isTrue() + repo.requestState(workUser, true) + assertThat(available?.get(workUser)).isTrue() } @Test(expected = IllegalArgumentException::class) @@ -129,7 +146,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) } @Test @@ -154,7 +171,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) } @Test @@ -173,7 +190,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) } @Test @@ -195,7 +212,7 @@ internal class UserRepositoryImplTest { val users by collectLastValue(repo.users) assertWithMessage("collectLastValue(repo.users)").that(users).isNotNull() - assertThat(users).containsExactly(SYSTEM, User(USER_SYSTEM, Role.PERSONAL)) + assertThat(users).containsExactly(User(USER_SYSTEM, Role.PERSONAL)) } } diff --git a/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt new file mode 100644 index 00000000..a81a315b --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/domain/interactor/UserInteractorTest.kt @@ -0,0 +1,208 @@ +/* + * 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.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 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlin.random.Random +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class UserInteractorTest { + private val baseId = Random.nextInt(1000, 2000) + + private val personalUser = User(id = baseId, role = Role.PERSONAL) + private val cloneUser = User(id = baseId + 1, role = Role.CLONE) + private val workUser = User(id = baseId + 2, role = Role.WORK) + private val privateUser = User(id = baseId + 3, role = Role.PRIVATE) + + val personalProfile = Profile(PERSONAL, personalUser) + val workProfile = Profile(WORK, workUser) + val privateProfile = Profile(PRIVATE, privateUser) + + @Test + fun launchedByProfile(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(listOf(personalUser, cloneUser)), + launchedAs = personalUser.handle + ) + + val launchedAsProfile by collectLastValue(profileInteractor.launchedAsProfile) + + assertThat(launchedAsProfile).isEqualTo(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun launchedByProfile_asClone(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(listOf(personalUser, cloneUser)), + launchedAs = cloneUser.handle + ) + val profiles by collectLastValue(profileInteractor.launchedAsProfile) + + assertThat(profiles).isEqualTo(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun profiles_withPersonal(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(listOf(personalUser)), + launchedAs = personalUser.handle + ) + + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser)) + } + + @Test + fun profiles_addClone(): Unit = runTest { + val fakeUserRepo = FakeUserRepository(listOf(personalUser)) + val profileInteractor = + UserInteractor(userRepository = fakeUserRepo, launchedAs = personalUser.handle) + + val profiles by collectLastValue(profileInteractor.profiles) + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser)) + + fakeUserRepo.addUser(cloneUser, available = true) + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun profiles_withPersonalAndClone(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = FakeUserRepository(listOf(personalUser, cloneUser)), + launchedAs = personalUser.handle + ) + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles).containsExactly(Profile(PERSONAL, personalUser, cloneUser)) + } + + @Test + fun profiles_withAllSupportedTypes(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = + FakeUserRepository(listOf(personalUser, cloneUser, workUser, privateUser)), + launchedAs = personalUser.handle + ) + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles) + .containsExactly( + Profile(PERSONAL, personalUser, cloneUser), + Profile(WORK, workUser), + Profile(PRIVATE, privateUser) + ) + } + + @Test + fun profiles_preservesIterationOrder(): Unit = runTest { + val profileInteractor = + UserInteractor( + userRepository = + FakeUserRepository(listOf(workUser, cloneUser, privateUser, personalUser)), + launchedAs = personalUser.handle + ) + + val profiles by collectLastValue(profileInteractor.profiles) + + assertThat(profiles) + .containsExactly( + Profile(WORK, workUser), + Profile(PRIVATE, privateUser), + Profile(PERSONAL, personalUser, cloneUser), + ) + } + + @Test + fun isAvailable_defaultValue() = runTest { + val userRepo = FakeUserRepository(listOf(personalUser)) + userRepo.addUser(workUser, false) + + val interactor = + UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + + val availability by collectLastValue(interactor.availability) + + assertWithMessage("personalAvailable").that(availability?.get(personalProfile)).isTrue() + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse() + } + + @Test + fun isAvailable() = runTest { + val userRepo = FakeUserRepository(listOf(workUser, personalUser)) + val interactor = + UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + + val availability by collectLastValue(interactor.availability) + + // Default state is enabled in FakeUserManager + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue() + + // Making user unavailable makes profile unavailable + userRepo.requestState(workUser, false) + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isFalse() + + // Making user available makes profile available again + userRepo.requestState(workUser, true) + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isTrue() + + // When a user is removed availability is removed as well. + userRepo.removeUser(workUser) + assertWithMessage("workAvailable").that(availability?.get(workProfile)).isNull() + } + + /** + * Similar to the above test in reverse: uses UserInteractor to modify state, and verify the + * state of the UserRepository. + */ + @Test + fun updateState() = runTest { + val userRepo = FakeUserRepository(listOf(workUser, personalUser)) + val userInteractor = + UserInteractor(userRepository = userRepo, launchedAs = personalUser.handle) + val workProfile = Profile(Profile.Type.WORK, workUser) + + val availability by collectLastValue(userRepo.availability) + + // Default state is enabled in FakeUserManager + assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue() + + userInteractor.updateState(workProfile, false) + assertWithMessage("workAvailable").that(availability?.get(workUser)).isFalse() + + userInteractor.updateState(workProfile, true) + assertWithMessage("workAvailable").that(availability?.get(workUser)).isTrue() + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt b/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt new file mode 100644 index 00000000..5eac6bd6 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ext/CreationExtrasExtTest.kt @@ -0,0 +1,54 @@ +/* + * 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.ext + +import android.graphics.Point +import androidx.core.os.bundleOf +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.test.ext.truth.os.BundleSubject.assertThat +import org.junit.Test + +class CreationExtrasExtTest { + @Test + fun addDefaultArgs_addsWhenAbsent() { + val creationExtras: CreationExtras = MutableCreationExtras() // empty + + val updated = creationExtras.addDefaultArgs("POINT" to Point(1, 1)) + + val defaultArgs = updated[DEFAULT_ARGS_KEY] + assertThat(defaultArgs).containsKey("POINT") + assertThat(defaultArgs).parcelable<Point>("POINT").marshallsEquallyTo(Point(1, 1)) + } + + @Test + fun addDefaultArgs_addsToExisting() { + val creationExtras: CreationExtras = + MutableCreationExtras().apply { + set(DEFAULT_ARGS_KEY, bundleOf("POINT1" to Point(1, 1))) + } + + val updated = creationExtras.addDefaultArgs("POINT2" to Point(2, 2)) + + val defaultArgs = updated[DEFAULT_ARGS_KEY] + assertThat(defaultArgs).containsKey("POINT1") + assertThat(defaultArgs).containsKey("POINT2") + assertThat(defaultArgs).parcelable<Point>("POINT1").marshallsEquallyTo(Point(1, 1)) + assertThat(defaultArgs).parcelable<Point>("POINT2").marshallsEquallyTo(Point(2, 2)) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt b/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt new file mode 100644 index 00000000..2ccd548a --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ext/IntentExtTest.kt @@ -0,0 +1,85 @@ +/* + * 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.ext + +import android.content.ComponentName +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import java.util.function.Predicate +import org.junit.Test + +class IntentExtTest { + + private val hasSendAction = + Predicate<Intent> { + it?.action == Intent.ACTION_SEND || it?.action == Intent.ACTION_SEND_MULTIPLE + } + + @Test + fun hasAction() { + val sendIntent = Intent(Intent.ACTION_SEND) + assertThat(sendIntent.hasAction(Intent.ACTION_SEND)).isTrue() + assertThat(sendIntent.hasAction(Intent.ACTION_VIEW)).isFalse() + } + + @Test + fun hasComponent() { + assertThat(Intent().hasComponent()).isFalse() + assertThat(Intent().setComponent(ComponentName("A", "B")).hasComponent()).isTrue() + } + + @Test + fun hasSendAction() { + assertThat(Intent(Intent.ACTION_SEND).hasSendAction()).isTrue() + assertThat(Intent(Intent.ACTION_SEND_MULTIPLE).hasSendAction()).isTrue() + assertThat(Intent(Intent.ACTION_SENDTO).hasSendAction()).isFalse() + assertThat(Intent(Intent.ACTION_VIEW).hasSendAction()).isFalse() + } + + @Test + fun hasSingleCategory() { + val intent = Intent().addCategory(Intent.CATEGORY_HOME) + assertThat(intent.hasSingleCategory(Intent.CATEGORY_HOME)).isTrue() + assertThat(intent.hasSingleCategory(Intent.CATEGORY_DEFAULT)).isFalse() + + intent.addCategory(Intent.CATEGORY_TEST) + assertThat(intent.hasSingleCategory(Intent.CATEGORY_TEST)).isFalse() + } + + @Test + fun ifMatch_matched() { + val sendIntent = Intent(Intent.ACTION_SEND) + val sendMultipleIntent = Intent(Intent.ACTION_SEND_MULTIPLE) + + sendIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } + sendMultipleIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } + assertWithMessage("sendIntent flags") + .that(sendIntent.flags) + .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) + assertWithMessage("sendMultipleIntent flags") + .that(sendMultipleIntent.flags) + .isEqualTo(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) + } + + @Test + fun ifMatch_notMatched() { + val viewIntent = Intent(Intent.ACTION_VIEW) + + viewIntent.ifMatch(hasSendAction) { addFlags(Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) } + assertWithMessage("viewIntent flags").that(viewIntent.flags).isEqualTo(0) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt b/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.kt index f5dc0935..5b6b5d99 100644 --- a/tests/unit/src/com/android/intentresolver/v2/MultiProfilePagerAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapterTest.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,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver.v2 +package com.android.intentresolver.v2.profiles import android.os.UserHandle import android.view.LayoutInflater @@ -55,7 +55,15 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -67,9 +75,8 @@ class MultiProfilePagerAdapterTest { 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.getPageAdapterForIndex(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) @@ -89,7 +96,16 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter, workListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -101,10 +117,9 @@ class MultiProfilePagerAdapterTest { 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.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(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) @@ -128,7 +143,16 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter, workListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), object : EmptyStateProvider {}, { false }, PROFILE_WORK, // <-- This test specifically requests we start on work profile. @@ -140,10 +164,9 @@ class MultiProfilePagerAdapterTest { 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.getPageAdapterForIndex(0)).isSameInstanceAs(personalListAdapter) + assertThat(pagerAdapter.getPageAdapterForIndex(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) @@ -163,7 +186,15 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -194,7 +225,15 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ) + ), object : EmptyStateProvider {}, { false }, PROFILE_PERSONAL, @@ -236,7 +275,16 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter, workListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), object : EmptyStateProvider {}, { true }, // <-- Work mode is quiet. PROFILE_WORK, @@ -270,7 +318,16 @@ class MultiProfilePagerAdapterTest { { listView: ListView, bindAdapter: ResolverListAdapter -> listView.setAdapter(bindAdapter) }, - ImmutableList.of(personalListAdapter, workListAdapter), + ImmutableList.of( + TabConfig( + PROFILE_PERSONAL, + "personal", + "personal_a11y", + "TAG_PERSONAL", + personalListAdapter + ), + TabConfig(PROFILE_WORK, "work", "work_a11y", "TAG_WORK", workListAdapter) + ), object : EmptyStateProvider {}, { false }, // <-- Work mode is not quiet. PROFILE_WORK, diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.kt new file mode 100644 index 00000000..371f9c26 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ui/ShareResultSenderImplTest.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.v2.ui + +import android.app.PendingIntent +import android.compat.testing.PlatformCompatChangeRule +import android.content.ComponentName +import android.content.Intent +import android.os.Process +import android.service.chooser.ChooserResult +import android.service.chooser.Flags +import androidx.test.InstrumentationRegistry +import com.android.intentresolver.inject.FakeChooserServiceFlags +import com.android.intentresolver.v2.ui.model.ShareAction +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import libcore.junit.util.compat.CoreCompatChangeRule.DisableCompatChanges +import libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule + +@OptIn(ExperimentalCoroutinesApi::class) +class ShareResultSenderImplTest { + + private val context = InstrumentationRegistry.getInstrumentation().context + + @get:Rule val compatChangeRule: TestRule = PlatformCompatChangeRule() + + val flags = FakeChooserServiceFlags() + + @OptIn(ExperimentalCoroutinesApi::class) + @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onComponentSelected_chooserResultEnabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred<Intent>() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) + runCurrent() + + val intentReceived = deferred.await() + val chooserResult = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult::class.java + ) + assertThat(chooserResult).isNotNull() + assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT) + assertThat(chooserResult?.selectedComponent).isEqualTo(ComponentName("example.com", "Foo")) + assertThat(chooserResult?.isShortcut).isTrue() + } + + @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onComponentSelected_chooserResultDisabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred<Intent>() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) + runCurrent() + + val intentReceived = deferred.await() + val componentName = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOSEN_COMPONENT, + ComponentName::class.java + ) + + assertWithMessage("EXTRA_CHOSEN_COMPONENT from received intent") + .that(componentName) + .isEqualTo(ComponentName("example.com", "Foo")) + + assertWithMessage("received intent has EXTRA_CHOOSER_RESULT") + .that(intentReceived.hasExtra(Intent.EXTRA_CHOOSER_RESULT)) + .isFalse() + } + + @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onActionSelected_chooserResultEnabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred<Intent>() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onActionSelected(ShareAction.SYSTEM_COPY) + runCurrent() + + val intentReceived = deferred.await() + val chosenComponent = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOSEN_COMPONENT, + ChooserResult::class.java + ) + assertThat(chosenComponent).isNull() + + val chooserResult = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult::class.java + ) + assertThat(chooserResult).isNotNull() + assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_COPY) + assertThat(chooserResult?.selectedComponent).isNull() + assertThat(chooserResult?.isShortcut).isFalse() + } + + @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onActionSelected_chooserResultDisabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred<Intent>() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + resultSender.onActionSelected(ShareAction.SYSTEM_COPY) + runCurrent() + + // No result should have been sent, this should never complete + assertWithMessage("deferred result isComplete").that(deferred.isCompleted).isFalse() + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt new file mode 100644 index 00000000..049fa001 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ui/model/ActivityModelTest.kt @@ -0,0 +1,107 @@ +/* + * 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.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.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test + +class ActivityModelTest { + + @Test + fun testDefaultValues() { + val input = ActivityModel(Intent(ACTION_CHOOSER), 0, "example.com", null) + + val output = input.toParcelAndBack() + + assertEquals(input, output) + } + + @Test + fun testCommonValues() { + val intent = Intent(ACTION_CHOOSER).apply { putExtra(EXTRA_TEXT, "Test") } + val input = + ActivityModel(intent, 1234, "com.example", Uri.parse("android-app://example.com")) + + val output = input.toParcelAndBack() + + assertEquals(input, output) + } + + @Test + fun testReferrerPackage_withAppReferrer_usesReferrer() { + val launch1 = + ActivityModel( + intent = Intent(), + launchedFromUid = 1000, + launchedFromPackage = "other.example.com", + referrer = Uri.parse("android-app://app.example.com") + ) + + assertThat(launch1.referrerPackage).isEqualTo("app.example.com") + } + + @Test + fun testReferrerPackage_httpReferrer_isNull() { + val launch = + ActivityModel( + intent = Intent(), + launchedFromUid = 1000, + launchedFromPackage = "example.com", + referrer = Uri.parse("http://some.other.value") + ) + + assertThat(launch.referrerPackage).isNull() + } + + @Test + fun testReferrerPackage_nullReferrer_isNull() { + val launch = + ActivityModel( + intent = Intent(), + launchedFromUid = 1000, + launchedFromPackage = "example.com", + referrer = null + ) + + assertThat(launch.referrerPackage).isNull() + } + + private fun assertEquals(expected: ActivityModel, actual: ActivityModel) { + // Test fields separately: Intent does not override equals() + assertWithMessage("%s.filterEquals(%s)", actual.intent, expected.intent) + .that(actual.intent.filterEquals(expected.intent)) + .isTrue() + + assertWithMessage("actual fromUid is equal to expected") + .that(actual.launchedFromUid) + .isEqualTo(expected.launchedFromUid) + + assertWithMessage("actual fromPackage is equal to expected") + .that(actual.launchedFromPackage) + .isEqualTo(expected.launchedFromPackage) + + assertWithMessage("actual referrer is equal to expected") + .that(actual.referrer) + .isEqualTo(expected.referrer) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt new file mode 100644 index 00000000..d3b9f559 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestTest.kt @@ -0,0 +1,297 @@ +/* + * 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.content.Intent +import android.content.Intent.ACTION_CHOOSER +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.ACTION_VIEW +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI +import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_REFERRER +import android.net.Uri +import android.service.chooser.Flags +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import com.android.intentresolver.ContentTypeHint +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.google.common.truth.Truth.assertThat +import org.junit.Test + +private fun createActivityModel( + targetIntent: Intent?, + referrer: Uri? = null, + additionalIntents: List<Intent>? = null +) = + ActivityModel( + Intent(ACTION_CHOOSER).apply { + targetIntent?.also { putExtra(EXTRA_INTENT, it) } + additionalIntents?.also { putExtra(EXTRA_ALTERNATE_INTENTS, it.toTypedArray()) } + }, + launchedFromUid = 10000, + launchedFromPackage = "com.android.example", + referrer = referrer ?: "android-app://com.android.example".toUri() + ) + +class ChooserRequestTest { + + private val fakeChooserServiceFlags = + 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 missingIntent() { + val model = createActivityModel(targetIntent = null) + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<ChooserRequest> + + assertThat(result.errors) + .containsExactly(NoValue(EXTRA_INTENT, Importance.CRITICAL, Intent::class)) + } + + @Test + fun referrerFillIn() { + val referrer = Uri.parse("android-app://example.com") + val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) + model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + val fillIn = result.value.getReferrerFillInIntent() + assertThat(fillIn.hasExtra(EXTRA_REFERRER)).isTrue() + assertThat(fillIn.getParcelableExtra(EXTRA_REFERRER, Uri::class.java)).isEqualTo(referrer) + } + + @Test + fun referrerPackage_isNullWithNonAppReferrer() { + val referrer = Uri.parse("http://example.com") + val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + + val model = createActivityModel(targetIntent = intent, referrer = referrer) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.referrerPackage).isNull() + } + + @Test + fun referrerPackage_fromAppReferrer() { + val referrer = Uri.parse("android-app://example.com") + val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) + + model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.referrerPackage).isEqualTo(referrer.authority) + } + + @Test + fun payloadIntents_includesTargetThenAdditional() { + val intent1 = Intent(ACTION_SEND) + val intent2 = Intent(ACTION_SEND_MULTIPLE) + val model = createActivityModel( + targetIntent = intent1, + additionalIntents = listOf(intent2) + ) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.payloadIntents).containsExactly(intent1, intent2) + } + + @Test + fun testRequest_withOnlyRequiredValues() { + val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + val model = createActivityModel(targetIntent = intent) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.launchedFromPackage).isEqualTo(model.launchedFromPackage) + } + + @Test + fun testRequest_actionSendWithAdditionalContentUri() { + 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_SEND)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.additionalContentUri).isEqualTo(uri) + assertThat(result.value.focusedItemPosition).isEqualTo(position) + } + + @Test + fun testRequest_actionSendWithAdditionalContentUri_parametersIgnoredWhenFlagDisabled() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) + val uri = Uri.parse("content://org.pkg/path") + val position = 10 + val model = + createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) + } + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + assertThat(result.warnings).isEmpty() + } + + @Test + fun testRequest_actionSendWithInvalidAdditionalContentUri() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) + val model = + createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "__invalid__") + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "__invalid__") + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + } + + @Test + fun testRequest_actionSendWithoutAdditionalContentUri() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) + val model = createActivityModel(targetIntent = Intent(ACTION_SEND)) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + } + + @Test + fun testRequest_actionViewWithAdditionalContentUri() { + 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 { + intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) + intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.additionalContentUri).isNull() + assertThat(result.value.focusedItemPosition).isEqualTo(0) + assertThat(result.warnings).isEmpty() + } + + @Test + fun testAlbumType() { + fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, true) + val model = createActivityModel(Intent(ACTION_SEND)) + model.intent.putExtra( + Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT, + Intent.CHOOSER_CONTENT_TYPE_ALBUM + ) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.contentTypeHint).isEqualTo(ContentTypeHint.ALBUM) + assertThat(result.warnings).isEmpty() + } + + @Test + 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 { + intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.metadataText).isNull() + } + + @Test + fun metadataText_whenFlagTrue_isPassedText() { + // Arrange + fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) + val metadataText: CharSequence = "Test metadata text" + val model = createActivityModel(targetIntent = Intent()).apply { + intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) + } + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.metadataText).isEqualTo(metadataText) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.kt new file mode 100644 index 00000000..6f1ed853 --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/v2/ui/viewmodel/ResolverRequestTest.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.v2.ui.viewmodel + +import android.content.Intent +import android.content.Intent.ACTION_VIEW +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.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test + +private val targetUri = Uri.parse("content://example.com/123") + +private fun createActivityModel( + targetIntent: Intent, + referrer: Uri? = null, +) = + ActivityModel( + intent = targetIntent, + launchedFromUid = 10000, + launchedFromPackage = "com.android.example", + referrer = referrer ?: "android-app://com.android.example".toUri() + ) + +class ResolverRequestTest { + @Test + fun testDefaults() { + val intent = Intent(ACTION_VIEW).apply { data = targetUri } + val activity = createActivityModel(intent) + + val result = readResolverRequest(activity) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ResolverRequest> + + assertThat(result.warnings).isEmpty() + + assertThat(result.value.intent.filterEquals(activity.intent)).isTrue() + assertThat(result.value.callingUser).isNull() + assertThat(result.value.selectedProfile).isNull() + } + + @Test + fun testInvalidSelectedProfile() { + val intent = + Intent(ACTION_VIEW).apply { + data = targetUri + putExtra(EXTRA_SELECTED_PROFILE, -1000) + } + + val activity = createActivityModel(intent) + + val result = readResolverRequest(activity) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<ResolverRequest> + + assertWithMessage("the first finding") + .that(result.errors.firstOrNull()) + .isInstanceOf(UncaughtException::class.java) + } + + @Test + fun payloadIntents_includesOnlyTarget() { + val intent2 = Intent(Intent.ACTION_SEND_MULTIPLE) + val intent1 = + Intent(Intent.ACTION_SEND).apply { + putParcelableArrayListExtra(Intent.EXTRA_ALTERNATE_INTENTS, arrayListOf(intent2)) + } + val activity = createActivityModel(targetIntent = intent1) + + val result = readResolverRequest(activity) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ResolverRequest> + + // Assert that payloadIntents does NOT include EXTRA_ALTERNATE_INTENTS + // that is only supported for Chooser and should be not be added here. + assertThat(result.value.payloadIntents).containsExactly(intent1) + } + + @Test + fun testAllValues() { + val intent = Intent(ACTION_VIEW).apply { data = Uri.parse("content://example.com/123") } + val activity = createActivityModel(targetIntent = intent) + + activity.intent.putExtras( + bundleOf( + EXTRA_CALLING_USER to UserHandle.of(123), + EXTRA_SELECTED_PROFILE to PROFILE_WORK, + EXTRA_IS_AUDIO_CAPTURE_DEVICE to true, + ) + ) + + val result = readResolverRequest(activity) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ResolverRequest> + + assertThat(result.value.intent.filterEquals(activity.intent)).isTrue() + assertThat(result.value.isAudioCaptureDevice).isTrue() + assertThat(result.value.callingUser).isEqualTo(UserHandle.of(123)) + assertThat(result.value.selectedProfile).isEqualTo(WORK) + } +} diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt index 43fb448c..dbaa7c4e 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/validation/ValidationTest.kt @@ -1,6 +1,5 @@ package com.android.intentresolver.v2.validation -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat import com.android.intentresolver.v2.validation.types.value import com.google.common.truth.Truth.assertThat import org.junit.Assert.fail @@ -16,8 +15,12 @@ class ValidationTest { val required: Int = required(value<Int>("key")) "return value: $required" } - assertThat(result).value().isEqualTo("return value: 1") - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<String> + + assertThat(result.value).isEqualTo("return value: 1") + assertThat(result.warnings).isEmpty() } /** Test reporting of absent required values. */ @@ -29,9 +32,12 @@ class ValidationTest { fail("'required' should have thrown an exception") "return value" } - assertThat(result).isFailure() - assertThat(result).findings().containsExactly( - RequiredValueMissing("key", Int::class)) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<String> + + assertThat(result.errors).containsExactly( + NoValue("key", Importance.CRITICAL, Int::class)) } /** Test optional values are ignored when absent. */ @@ -42,20 +48,28 @@ class ValidationTest { val optional: Int? = optional(value<Int>("key")) "return value: $optional" } - assertThat(result).value().isEqualTo("return value: 1") - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<String> + + assertThat(result.value).isEqualTo("return value: 1") + assertThat(result.warnings).isEmpty() } /** Test optional values are ignored when absent. */ @Test fun optional_valueAbsent() { - val result: ValidationResult<String?> = + val result: ValidationResult<String> = validateFrom({ null }) { val optional: String? = optional(value<String>("key")) "return value: $optional" } - assertThat(result).isSuccess() - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<String> + + assertThat(result.value).isEqualTo("return value: null") + assertThat(result.warnings).isEmpty() } /** Test reporting of ignored values. */ @@ -66,9 +80,12 @@ class ValidationTest { ignored(value<Int>("key"), "no longer supported") "result value" } - assertThat(result).value().isEqualTo("result value") - assertThat(result) - .findings() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<String> + + assertThat(result.value).isEqualTo("result value") + assertThat(result.warnings) .containsExactly(IgnoredValue("key", "no longer supported")) } @@ -80,8 +97,11 @@ class ValidationTest { ignored(value<Int>("key"), "ignored when option foo is set") "result value" } - assertThat(result).value().isEqualTo("result value") - assertThat(result).findings().isEmpty() + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<String> + + assertThat(result.value).isEqualTo("result value") + assertThat(result.warnings).isEmpty() } /** Test handling of exceptions in the validation function. */ @@ -91,9 +111,12 @@ class ValidationTest { validateFrom({ null }) { error("something") } - assertThat(result).isFailure() - val findingTypes = result.findings.map { it::class } - assertThat(findingTypes.first()).isEqualTo(UncaughtException::class) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<String> + + 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/v2/validation/types/IntentOrUriTest.kt index ad230488..03429f4c 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/validation/types/IntentOrUriTest.kt @@ -7,8 +7,9 @@ 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.RequiredValueMissing -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +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.google.common.truth.Truth.assertThat import org.junit.Test @@ -22,7 +23,9 @@ class IntentOrUriTest { val values = mapOf("key" to Intent("GO")) val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<Intent> assertThat(result.value).hasAction("GO") } @@ -33,7 +36,9 @@ class IntentOrUriTest { val values = mapOf("key" to Intent("GO").toUri(URI_INTENT_SCHEME).toUri()) val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).findings().isEmpty() + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<Intent> assertThat(result.value).hasAction("GO") } @@ -44,8 +49,11 @@ class IntentOrUriTest { val result = keyValidator.validate({ null }, CRITICAL) - assertThat(result).value().isNull() - assertThat(result).findings().containsExactly(RequiredValueMissing("key", Intent::class)) + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<Intent> + + assertThat(result.errors) + .containsExactly(NoValue("key", CRITICAL, Intent::class)) } /** Check validation passes when value is null and importance is [WARNING] (optional). */ @@ -55,8 +63,9 @@ class IntentOrUriTest { val result = keyValidator.validate(source = { null }, WARNING) - assertThat(result).findings().isEmpty() - assertThat(result.value).isNull() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<List<Intent>> + assertThat(result.errors).isEmpty() } /** @@ -69,9 +78,10 @@ class IntentOrUriTest { val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).value().isNull() - assertThat(result) - .findings() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<Intent> + + assertThat(result.errors) .containsExactly( ValueIsWrongType( "key", @@ -92,9 +102,10 @@ class IntentOrUriTest { val result = keyValidator.validate(values::get, WARNING) - assertThat(result).value().isNull() - assertThat(result) - .findings() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<Intent> + + assertThat(result.errors) .containsExactly( ValueIsWrongType( "key", diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt index d4dca01b..637873ea 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/validation/types/ParceledArrayTest.kt @@ -4,8 +4,9 @@ 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.RequiredValueMissing -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +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.google.common.truth.Truth.assertThat @@ -21,7 +22,8 @@ class ParceledArrayTest { val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).findings().isEmpty() + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<List<String>> assertThat(result.value).containsExactly("String") } @@ -33,9 +35,10 @@ class ParceledArrayTest { val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).value().isNull() - assertThat(result) - .findings() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<List<Intent>> + + assertThat(result.errors) .containsExactly( // TODO: report with a new class `WrongElementType` to improve clarity WrongElementType( @@ -55,8 +58,10 @@ class ParceledArrayTest { val result = keyValidator.validate(source = { null }, CRITICAL) - assertThat(result).value().isNull() - assertThat(result).findings().containsExactly(RequiredValueMissing("key", Intent::class)) + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<List<Intent>> + + assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Intent::class)) } /** Check validation passes when value is null and importance is [WARNING] (optional). */ @@ -66,8 +71,10 @@ class ParceledArrayTest { val result = keyValidator.validate(source = { null }, WARNING) - assertThat(result).findings().isEmpty() - assertThat(result.value).isNull() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<List<Intent>> + + assertThat(result.errors).isEmpty() } /** Check correct failure result when the array value itself is the wrong type. */ @@ -78,9 +85,10 @@ class ParceledArrayTest { val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).value().isNull() - assertThat(result) - .findings() + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<List<Intent>> + + assertThat(result.errors) .containsExactly( ValueIsWrongType( "key", diff --git a/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt b/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt index 13bb4b33..93d76d46 100644 --- a/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt +++ b/tests/unit/src/com/android/intentresolver/v2/validation/types/SimpleValueTest.kt @@ -1,10 +1,13 @@ package com.android.intentresolver.v2.validation.types import com.android.intentresolver.v2.validation.Importance.CRITICAL -import com.android.intentresolver.v2.validation.RequiredValueMissing -import com.android.intentresolver.v2.validation.ValidationResultSubject.Companion.assertThat +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 +import com.google.common.truth.Truth.assertThat class SimpleValueTest { @@ -15,8 +18,11 @@ class SimpleValueTest { val values = mapOf("key" to Math.PI) val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).findings().isEmpty() - assertThat(result).value().isEqualTo(Math.PI) + + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<Double> + assertThat(result.value).isEqualTo(Math.PI) } /** Test for validation success when the value is present and the correct type. */ @@ -26,17 +32,17 @@ class SimpleValueTest { val values = mapOf("key" to "Apple Pie") val result = keyValidator.validate(values::get, CRITICAL) - assertThat(result).value().isNull() - assertThat(result) - .findings() - .containsExactly( - ValueIsWrongType( - "key", - importance = CRITICAL, - actualType = String::class, - allowedTypes = listOf(Double::class) - ) + + 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) ) + ) } /** Test the failure result when the value is missing. */ @@ -46,7 +52,26 @@ class SimpleValueTest { val result = keyValidator.validate(source = { null }, CRITICAL) - assertThat(result).value().isNull() - assertThat(result).findings().containsExactly(RequiredValueMissing("key", Double::class)) + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<Double> + + assertThat(result.errors).containsExactly(NoValue("key", CRITICAL, Double::class)) + } + + + /** Test the failure result when the value is missing. */ + @Test + fun optional() { + val keyValidator = SimpleValue("key", expected = Double::class) + + val result = keyValidator.validate(source = { null }, WARNING) + + assertThat(result).isInstanceOf(Invalid::class.java) + result as Invalid<Double> + + // Note: As single optional validation result, the return must be Invalid + // when there is no value to return, but no errors will be reported because + // an optional value cannot be "missing". + assertThat(result.errors).isEmpty() } } |