diff options
Diffstat (limited to 'java')
144 files changed, 4203 insertions, 1227 deletions
diff --git a/java/res/layout/chooser_grid_scrollable_preview.xml b/java/res/layout/chooser_grid_scrollable_preview.xml index c1bcf912..02584d27 100644 --- a/java/res/layout/chooser_grid_scrollable_preview.xml +++ b/java/res/layout/chooser_grid_scrollable_preview.xml @@ -78,6 +78,7 @@ </FrameLayout> <com.android.intentresolver.widget.ChooserNestedScrollView + android:id="@+id/chooser_scrollable_container" android:layout_width="match_parent" android:layout_height="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 fc0431d7..d48fcb50 100644 --- a/java/res/layout/chooser_list_per_profile_wrap.xml +++ b/java/res/layout/chooser_list_per_profile_wrap.xml @@ -18,14 +18,7 @@ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" - android:layout_height="wrap_content" - android:descendantFocusability="blocksDescendants"> - <!-- ^^^ Block descendants from receiving focus to prevent NestedScrollView - (ChooserNestedScrollView) scrolling to the focused view when switching tabs. Without it, TabHost - view will request focus on the newly activated tab. The RecyclerView from this layout gets - focused and notifies its parents (including NestedScrollView) about it through - #requestChildFocus method call. NestedScrollView's view implementation of the method will - scroll to the focused view. --> + android:layout_height="wrap_content"> <androidx.recyclerview.widget.RecyclerView android:layout_width="match_parent" diff --git a/java/res/values-af/strings.xml b/java/res/values-af/strings.xml index bfe3e7dc..55d84dfa 100644 --- a/java/res/values-af/strings.xml +++ b/java/res/values-af/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deel tans prent}other{Deel tans # prente}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deel tans video}other{Deel tans # video’s}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deel tans # lêer}other{Deel tans # lêers}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Kies items om te deel"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deel tans prent met teks}other{Deel tans # prente met teks}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deel tans prent met skakel}other{Deel tans # prente met skakel}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deel tans video met teks}other{Deel tans # video’s met teks}}"</string> diff --git a/java/res/values-am/strings.xml b/java/res/values-am/strings.xml index 6daccad9..a7b5922b 100644 --- a/java/res/values-am/strings.xml +++ b/java/res/values-am/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ምስልን በማጋራት ላይ}one{# ምስልን በማጋራት ላይ}other{# ምስሎችን በማጋራት ላይ}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ቪድዮ በማጋራት ላይ}one{# ቪድዮ በማጋራት ላይ}other{# ቪድዮዎችን በማጋራት ላይ}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ፋይልን በማጋራት ላይ}one{# ፋይልን በማጋራት ላይ}other{# ፋይሎችን በማጋራት ላይ}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"ለማጋራት ንጥሎችን ምረጥ"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ምስልን ከጽሑፍ ጋር በማጋራት ላይ}one{# ምስልን ከጽሑፍ ጋር በማጋራት ላይ}other{# ምስሎችን ከጽሑፍ ጋር በማጋራት ላይ}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ምስልን ከአገናኝ ጋር በማጋራት ላይ}one{# ምስልን ከአገናኝ ጋር በማጋራት ላይ}other{# ምስሎችን ከአገናኝ ጋር በማጋራት ላይ}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ቪድዮ ከጽሑፍ ጋር በማጋራት ላይ}one{# ቪድዮ ከጽሑፍ ጋር በማጋራት ላይ}other{# ቪድዮዎችን ከጽሑፍ ጋር በማጋራት ላይ}}"</string> diff --git a/java/res/values-ar/strings.xml b/java/res/values-ar/strings.xml index c50ac67e..49769c57 100644 --- a/java/res/values-ar/strings.xml +++ b/java/res/values-ar/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{جارٍ مشاركة صورة واحدة}zero{جارٍ مشاركة # صورة}two{جارٍ مشاركة صورتَين}few{جارٍ مشاركة # صور}many{جارٍ مشاركة # صورة}other{جارٍ مشاركة # صورة}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{جارٍ مشاركة فيديو واحد}zero{جارٍ مشاركة # فيديو}two{جارٍ مشاركة فيديوهَين}few{جارٍ مشاركة # فيديوهات}many{جارٍ مشاركة # فيديو}other{جارٍ مشاركة # فيديو}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{مشاركة ملف واحد}zero{مشاركة # ملف}two{مشاركة ملفَّين}few{مشاركة # ملفات}many{مشاركة # ملفًّا}other{مشاركة # ملف}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"اختيار العناصر المراد مشاركتها"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{مشاركة صورة واحدة ونص}zero{مشاركة # صورة ونص}two{مشاركة صورتَين ونص}few{مشاركة # صور ونص}many{مشاركة # صورة ونص}other{مشاركة # صورة ونص}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{مشاركة صورة واحدة ورابط}zero{مشاركة # صورة ورابط}two{مشاركة # صورتَين ورابط}few{مشاركة # صور ورابط}many{مشاركة # صورة ورابط}other{مشاركة # صورة ورابط}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{مشاركة فيديو واحد ونص}zero{مشاركة # فيديو ونص}two{مشاركة فيديوهَين ونص}few{مشاركة # فيديوهات ونص}many{مشاركة # فيديو ونص}other{مشاركة # فيديو ونص}}"</string> @@ -75,7 +76,7 @@ <string name="file_preview_a11y_description" msgid="7397224827802410602">"صورة مصغّرة لمعاينة ملف"</string> <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"ما مِن أشخاص مقترحين للمشاركة معهم"</string> <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"لم يتم منح هذا التطبيق إذن تسجيل، ولكن يمكنه تسجيل الصوت من خلال جهاز USB هذا."</string> - <string name="resolver_personal_tab" msgid="1381052735324320565">"مساحة شخصية"</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> diff --git a/java/res/values-as/strings.xml b/java/res/values-as/strings.xml index d2b3cb69..1983e4fe 100644 --- a/java/res/values-as/strings.xml +++ b/java/res/values-as/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}one{# খন প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}other{# খন প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}one{# টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}other{# টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}one{# টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}other{# টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"শ্বেয়াৰ কৰাৰ বাবে বস্তু বাছনি কৰক"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{পাঠৰ সৈতে প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}one{পাঠৰ সৈতে # টা প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}other{পাঠৰ সৈতে # টা প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{লিংকৰ সৈতে প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}one{লিংকৰ সৈতে # টা প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}other{লিংকৰ সৈতে # টা প্ৰতিচ্ছবি শ্বেয়াৰ কৰি থকা হৈছে}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{পাঠৰ সৈতে ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}one{পাঠৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}other{পাঠৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}}"</string> diff --git a/java/res/values-az/strings.xml b/java/res/values-az/strings.xml index e8915892..c5674b86 100644 --- a/java/res/values-az/strings.xml +++ b/java/res/values-az/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Şəkil paylaşılır}other{# şəkil paylaşılır}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video paylaşılır}other{# video paylaşılır}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# fayl paylaşılır}other{# fayl paylaşılır}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Paylaşmaq üçün elementlər seçin"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Mətn olan şəkil paylaşılır}other{Mətn olan # şəkil paylaşılır}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Link olan şəkil paylaşılır}other{Link olan # şəkil paylaşılır}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Mətn olan video paylaşılır}other{Mətn olan # video paylaşılır}}"</string> diff --git a/java/res/values-b+sr+Latn/strings.xml b/java/res/values-b+sr+Latn/strings.xml index 228576f6..6d9dbd87 100644 --- a/java/res/values-b+sr+Latn/strings.xml +++ b/java/res/values-b+sr+Latn/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Izaberite stavke za deljenje"</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> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deli se slika sa linkom}one{Deli se # slika sa linkom}few{Dele se # slike sa linkom}other{Deli se # slika sa linkom}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deli se video sa tekstom}one{Deli se # video sa tekstom}few{Dele se # video snimka sa tekstom}other{Deli se # videa sa tekstom}}"</string> diff --git a/java/res/values-be/strings.xml b/java/res/values-be/strings.xml index 22079a0d..2724855b 100644 --- a/java/res/values-be/strings.xml +++ b/java/res/values-be/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Абагульванне відарыса}one{Абагульванне # відарыса}few{Абагульванне # відарысаў}many{Абагульванне # відарысаў}other{Абагульванне # відарыса}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Абагульванне відэа}one{Абагульванне # відэа}few{Абагульванне # відэа}many{Абагульванне # відэа}other{Абагульванне # відэа}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Абагульваецца # файл}one{Абагульваецца # файл}few{Абагульваюцца # файлы}many{Абагульваюцца # файлаў}other{Абагульваюцца # файла}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Выберыце элементы для абагульвання"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Абагульванне відарыса з тэкстам}one{Абагульванне # відарыса з тэкстам}few{Абагульванне # відарысаў з тэкстам}many{Абагульванне # відарысаў з тэкстам}other{Абагульванне # відарыса з тэкстам}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Абагульванне відарыса са спасылкай}one{Абагульванне # відарыса са спасылкай}few{Абагульванне # відарысаў са спасылкай}many{Абагульванне # відарысаў са спасылкай}other{Абагульванне # відарыса са спасылкай}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Абагульванне відэа з тэкстам}one{Абагульванне # відэа з тэкстам}few{Абагульванне # відэа з тэкстам}many{Абагульванне # відэа з тэкстам}other{Абагульванне # відэа з тэкстам}}"</string> diff --git a/java/res/values-bg/strings.xml b/java/res/values-bg/strings.xml index 0b5fcad5..450712b1 100644 --- a/java/res/values-bg/strings.xml +++ b/java/res/values-bg/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Изберете елементи за споделяне"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Споделяне на изображението чрез SMS съобщение}other{Споделяне на # изображения чрез SMS съобщение}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Споделяне на изображението чрез връзка}other{Споделяне на # изображения чрез връзка}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Споделяне на видеоклипа чрез SMS съобщение}other{Споделяне на # видеоклипа чрез SMS съобщение}}"</string> diff --git a/java/res/values-bn/strings.xml b/java/res/values-bn/strings.xml index b0d433c1..2d33eb29 100644 --- a/java/res/values-bn/strings.xml +++ b/java/res/values-bn/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ছবি শেয়ার করা হচ্ছে}one{#টি ছবি শেয়ার করা হচ্ছে}other{#টি ছবি শেয়ার করা হচ্ছে}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ভিডিও শেয়ার করা হচ্ছে}one{#টি ভিডিও শেয়ার করা হচ্ছে}other{#টি ভিডিও শেয়ার করা হচ্ছে}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{#টি ফাইল শেয়ার করা হচ্ছে}one{#টি ফাইল শেয়ার করা হচ্ছে}other{#টি ফাইল শেয়ার করা হচ্ছে}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"শেয়ার করার জন্য আইটেম বেছে নিন"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{টেক্সট সহ ছবি শেয়ার করা হচ্ছে}one{টেক্সট সহ #টি ছবি শেয়ার করা হচ্ছে}other{টেক্সট সহ #টি ছবি শেয়ার করা হচ্ছে}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{লিঙ্ক সহ ছবি শেয়ার করা হচ্ছে}one{লিঙ্ক সহ #টি ছবি শেয়ার করা হচ্ছে}other{লিঙ্ক সহ #টি ছবি শেয়ার করা হচ্ছে}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{টেক্সট সহ ভিডিও শেয়ার করা হচ্ছে}one{টেক্সট সহ #টি ভিডিও শেয়ার করা হচ্ছে}other{টেক্সট সহ #টি ভিডিও শেয়ার করা হচ্ছে}}"</string> diff --git a/java/res/values-bs/strings.xml b/java/res/values-bs/strings.xml index 97d3e7cf..10335fab 100644 --- a/java/res/values-bs/strings.xml +++ b/java/res/values-bs/strings.xml @@ -60,6 +60,7 @@ <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{Dijeljenje videozapisa}one{Dijeljenje # videozapisa}few{Dijeljenje # videozapisa}other{Dijeljenje # videozapisa}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Dijeljenje # fajla}one{Dijeljenje # fajla}few{Dijeljenje # fajla}other{Dijeljenje # fajlova}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Odaberite stavke za dijeljenje"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Dijeljenje slike putem poruke}one{Dijeljenje # slike putem poruke}few{Dijeljenje # slike putem poruke}other{Dijeljenje # slika putem poruke}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Dijeljenje slike putem linka}one{Dijeljenje # slike putem linka}few{Dijeljenje # slike putem linka}other{Dijeljenje # slika putem linka}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Dijeljenje videozapisa putem poruke}one{Dijeljenje # videozapisa putem poruke}few{Dijeljenje # videozapisa putem poruke}other{Dijeljenje # videozapisa putem poruke}}"</string> diff --git a/java/res/values-ca/strings.xml b/java/res/values-ca/strings.xml index 4cc905ba..11029365 100644 --- a/java/res/values-ca/strings.xml +++ b/java/res/values-ca/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Selecciona els elements que vols compartir"</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> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{S\'està compartint la imatge amb un enllaç}many{S\'estan compartint # d\'imatges amb un enllaç}other{S\'estan compartint # imatges amb un enllaç}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{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> diff --git a/java/res/values-cs/strings.xml b/java/res/values-cs/strings.xml index cca5091d..0ce7e140 100644 --- a/java/res/values-cs/strings.xml +++ b/java/res/values-cs/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sdílení obrázku}few{Sdílení # obrázků}many{Sdílení # obrázku}other{Sdílení # obrázků}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sdílení videa}few{Sdílení # videí}many{Sdílení # videa}other{Sdílení # videí}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sdílení # souboru}few{Sdílení # souborů}many{Sdílení # souboru}other{Sdílení # souborů}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Vyberte položky, které chcete sdílet"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sdílení obrázku s textem}few{Sdílení # obrázků s textem}many{Sdílení # obrázku s textem}other{Sdílení # obrázků s textem}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sdílení obrázku s odkazem}few{Sdílení # obrázků s odkazem}many{Sdílení # obrázku s odkazem}other{Sdílení # obrázků s odkazem}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sdílení videa s textem}few{Sdílení # videí s textem}many{Sdílení # videa s textem}other{Sdílení # videí s textem}}"</string> diff --git a/java/res/values-da/strings.xml b/java/res/values-da/strings.xml index f0d27442..3a3e2062 100644 --- a/java/res/values-da/strings.xml +++ b/java/res/values-da/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deler billede}one{Deler # billede}other{Deler # billeder}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deler video}one{Deler # video}other{Deler # videoer}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deler # fil}one{Deler # fil}other{Deler # filer}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Vælg elementer til deling"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deler billede med tekst}one{Deler # billede med tekst}other{Deler # billeder med tekst}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deler billede med et link}one{Deler # billede med et link}other{Deler # billeder med et link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deler video med tekst}one{Deler # video med tekst}other{Deler # videoer med tekst}}"</string> diff --git a/java/res/values-de/strings.xml b/java/res/values-de/strings.xml index 3d2250a6..3a561101 100644 --- a/java/res/values-de/strings.xml +++ b/java/res/values-de/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Bild wird geteilt}other{# Bilder werden geteilt}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video wird geteilt}other{# Videos werden geteilt}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# Datei wird freigegeben}other{# Dateien werden freigegeben}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Elemente zum Teilen auswählen"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Bild wird mit Text geteilt}other{# Bilder werden mit Text geteilt}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Bild wird per Link geteilt}other{# Bilder werden per Link geteilt}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Video wird per SMS geteilt}other{# Videos werden per SMS geteilt}}"</string> diff --git a/java/res/values-el/strings.xml b/java/res/values-el/strings.xml index ed09f127..8903eec1 100644 --- a/java/res/values-el/strings.xml +++ b/java/res/values-el/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Επιλογή στοιχείων για κοινή χρήση"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Κοινοποίηση εικόνας με κείμενο}other{Κοινοποίηση # εικόνων με κείμενο}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Κοινοποίηση εικόνας με σύνδεσμο}other{Κοινοποίηση # εικόνων με σύνδεσμο}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Κοινοποίηση βίντεο με κείμενο}other{Κοινοποίηση # βίντεο με κείμενο}}"</string> diff --git a/java/res/values-en-rAU/strings.xml b/java/res/values-en-rAU/strings.xml index 88e86718..53e64659 100644 --- a/java/res/values-en-rAU/strings.xml +++ b/java/res/values-en-rAU/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sharing image}other{Sharing # images}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sharing video}other{Sharing # videos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sharing # file}other{Sharing # files}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Select items to share"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sharing image with text}other{Sharing # images with text}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sharing image with link}other{Sharing # images with link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sharing video with text}other{Sharing # videos with text}}"</string> diff --git a/java/res/values-en-rCA/strings.xml b/java/res/values-en-rCA/strings.xml index 978da764..1c44b945 100644 --- a/java/res/values-en-rCA/strings.xml +++ b/java/res/values-en-rCA/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sharing image}other{Sharing # images}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sharing video}other{Sharing # videos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sharing # file}other{Sharing # files}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Select items to share"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sharing image with text}other{Sharing # images with text}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sharing image with link}other{Sharing # images with link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sharing video with text}other{Sharing # videos with text}}"</string> diff --git a/java/res/values-en-rGB/strings.xml b/java/res/values-en-rGB/strings.xml index 88e86718..53e64659 100644 --- a/java/res/values-en-rGB/strings.xml +++ b/java/res/values-en-rGB/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sharing image}other{Sharing # images}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sharing video}other{Sharing # videos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sharing # file}other{Sharing # files}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Select items to share"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sharing image with text}other{Sharing # images with text}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sharing image with link}other{Sharing # images with link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sharing video with text}other{Sharing # videos with text}}"</string> diff --git a/java/res/values-en-rIN/strings.xml b/java/res/values-en-rIN/strings.xml index 88e86718..53e64659 100644 --- a/java/res/values-en-rIN/strings.xml +++ b/java/res/values-en-rIN/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sharing image}other{Sharing # images}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sharing video}other{Sharing # videos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sharing # file}other{Sharing # files}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Select items to share"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sharing image with text}other{Sharing # images with text}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sharing image with link}other{Sharing # images with link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sharing video with text}other{Sharing # videos with text}}"</string> diff --git a/java/res/values-en-rXC/strings.xml b/java/res/values-en-rXC/strings.xml index 7447d83b..4fc18b62 100644 --- a/java/res/values-en-rXC/strings.xml +++ b/java/res/values-en-rXC/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Sharing image}other{Sharing # images}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Sharing video}other{Sharing # videos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Sharing # file}other{Sharing # files}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Select items to share"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Sharing image with text}other{Sharing # images with text}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Sharing image with link}other{Sharing # images with link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Sharing video with text}other{Sharing # videos with text}}"</string> diff --git a/java/res/values-es-rUS/strings.xml b/java/res/values-es-rUS/strings.xml index 7c96aa65..f3b7fe85 100644 --- a/java/res/values-es-rUS/strings.xml +++ b/java/res/values-es-rUS/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartir la imagen}many{Compartir # de imágenes}other{Compartir # imágenes}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartiendo video}many{Compartiendo # de videos}other{Compartiendo # videos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartiendo # archivo}many{Compartiendo # de archivos}other{Compartiendo # archivos}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Selecciona los elementos para compartir"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartir imagen con texto}many{Compartir # de imágenes con texto}other{Compartir # imágenes con texto}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartir imagen con vínculo}many{Compartir # de imágenes con vínculo}other{Compartir # imágenes con vínculo}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartir video con texto}many{Compartir # de videos con texto}other{Compartir # videos con texto}}"</string> diff --git a/java/res/values-es/strings.xml b/java/res/values-es/strings.xml index ca392a1f..460de896 100644 --- a/java/res/values-es/strings.xml +++ b/java/res/values-es/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartiendo imagen}many{Compartiendo # imágenes}other{Compartiendo # 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="select_items_to_share" msgid="1026071777275022579">"Selecciona los elementos que quieres compartir"</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> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartiendo imagen con enlace}many{Compartiendo # imágenes con enlace}other{Compartiendo # imágenes con enlace}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartiendo vídeo con texto}many{Compartiendo # vídeos con texto}other{Compartiendo # vídeos con texto}}"</string> diff --git a/java/res/values-et/strings.xml b/java/res/values-et/strings.xml index ab849b2c..85fca08f 100644 --- a/java/res/values-et/strings.xml +++ b/java/res/values-et/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Pildi jagamine}other{# pildi jagamine}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video jagamine}other{# video jagamine}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# faili jagamine}other{# faili jagamine}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Jagatavate üksuste valimine"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Teksti sisaldava pildi jagamine}other{# teksti sisaldava pildi jagamine}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Linki sisaldava pildi jagamine}other{# linki sisaldava pildi jagamine}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Teksti sisaldava video jagamine}other{# teksti sisaldava video jagamine}}"</string> diff --git a/java/res/values-eu/strings.xml b/java/res/values-eu/strings.xml index a3269d72..5020f62d 100644 --- a/java/res/values-eu/strings.xml +++ b/java/res/values-eu/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Irudia partekatuko da}other{# irudi partekatuko dira}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Bideoa partekatzen}other{# bideo partekatzen}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# fitxategi partekatuko da}other{# fitxategi partekatuko dira}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Hautatu partekatu beharreko elementuak"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Irudi testudun bat partekatuko da}other{# irudi testudun partekatuko dira}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Irudi estekadun bat partekatuko da}other{# irudi estekadun partekatuko dira}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Bideo testudun bat partekatuko da}other{# bideo testudun partekatuko dira}}"</string> diff --git a/java/res/values-fa/strings.xml b/java/res/values-fa/strings.xml index 2f180977..7b3dc6ea 100644 --- a/java/res/values-fa/strings.xml +++ b/java/res/values-fa/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{همرسانی تصویر}one{همرسانی # تصویر}other{همرسانی # تصویر}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{درحال همرسانی ویدیو}one{درحال همرسانی # ویدیو}other{درحال همرسانی # ویدیو}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{همرسانی # فایل}one{همرسانی # فایل}other{همرسانی # فایل}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"انتخاب کردن موارد برای همرسانی"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{درحال همرسانی تصویر با نوشتار}one{درحال همرسانی # تصویر با نوشتار}other{درحال همرسانی # تصویر با نوشتار}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{درحال همرسانی تصویر با پیوند}one{درحال همرسانی # تصویر با پیوند}other{درحال همرسانی # تصویر با پیوند}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{درحال همرسانی ویدیو با نوشتار}one{درحال همرسانی # ویدیو با نوشتار}other{درحال همرسانی # ویدیو با نوشتار}}"</string> diff --git a/java/res/values-fi/strings.xml b/java/res/values-fi/strings.xml index ee740f13..65244293 100644 --- a/java/res/values-fi/strings.xml +++ b/java/res/values-fi/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Jaetaan kuvaa}other{Jaetaan # kuvaa}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Jaetaan videota}other{Jaetaan # videota}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Jaetaan # tiedosto}other{Jaetaan # tiedostoa}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Valitse jaettavat kohteet"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Kuvaa ja tekstiä jaetaan}other{# kuvaa ja tekstiä jaetaan}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Kuvaa ja linkkiä jaetaan}other{# kuvaa ja linkkiä jaetaan}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Videota ja tekstiä jaetaan}other{# videota ja tekstiä jaetaan}}"</string> diff --git a/java/res/values-fr-rCA/strings.xml b/java/res/values-fr-rCA/strings.xml index 9d1a8bb9..b2ae5f5c 100644 --- a/java/res/values-fr-rCA/strings.xml +++ b/java/res/values-fr-rCA/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Partage d\'une image}one{Partage de # image}many{Partage de # d\'images}other{Partage de # images}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Partage de la vidéo…}one{Partage de # vidéo…}many{Partage de # de vidéos…}other{Partage de # vidéos…}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Partage de # fichier en cours…}one{Partage de # fichier en cours…}many{Partage de # de fichiers en cours…}other{Partage de # fichiers en cours…}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Sélectionner les éléments à partager"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Partage d\'une image avec du texte}one{Partage de # image avec du texte}many{Partage de # d\'images avec du texte}other{Partage de # images avec du texte}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Partage d\'une image avec un lien}one{Partage de # image avec un lien}many{Partage de # d\'images avec un lien}other{Partage de # images avec un lien}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Partage d\'une vidéo avec du texte}one{Partage de # vidéo avec du texte}many{Partage de # de vidéos avec du texte}other{Partage de # vidéos avec du texte}}"</string> diff --git a/java/res/values-fr/strings.xml b/java/res/values-fr/strings.xml index 6f55cbf9..2b96c92f 100644 --- a/java/res/values-fr/strings.xml +++ b/java/res/values-fr/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Partager l\'image}one{Partager # image}many{Partager # d\'images}other{Partager # images}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Partage de la vidéo…}one{Partage de # vidéo…}many{Partage de # de vidéos…}other{Partage de # vidéos…}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Partage de # fichier}one{Partage de # fichier}many{Partage de # fichiers}other{Partage de # fichiers}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Sélectionner les éléments à partager"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Partager 1 image avec du texte}one{Partager # image avec du texte}many{Partager # images avec du texte}other{Partager # images avec du texte}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Partager 1 image avec un lien}one{Partager # image avec un lien}many{Partager # images avec un lien}other{Partager # images avec un lien}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Partager 1 vidéo avec du texte}one{Partager # vidéo avec du texte}many{Partager # vidéos avec du texte}other{Partager # vidéos avec du texte}}"</string> diff --git a/java/res/values-gl/strings.xml b/java/res/values-gl/strings.xml index fe59eaa6..a8caf6f3 100644 --- a/java/res/values-gl/strings.xml +++ b/java/res/values-gl/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Compartindo imaxe}other{Compartindo # imaxes}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Compartindo vídeo}other{Compartindo # vídeos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Compartindo # ficheiro}other{Compartindo # ficheiros}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Seleccionar elementos para compartir"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Compartindo imaxe con texto}other{Compartindo # imaxes con texto}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartindo imaxe con ligazón}other{Compartindo # imaxes con ligazón}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartindo vídeo con texto}other{Compartindo # vídeos con texto}}"</string> diff --git a/java/res/values-gu/strings.xml b/java/res/values-gu/strings.xml index 70d84bc8..a70a1b0f 100644 --- a/java/res/values-gu/strings.xml +++ b/java/res/values-gu/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{છબી શેર કરી રહ્યાં છીએ}one{# છબી શેર કરી રહ્યાં છીએ}other{# છબી શેર કરી રહ્યાં છીએ}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{વીડિયો શેર કરીએ છીએ}one{# વીડિયો શેર કરીએ છીએ}other{# વીડિયો શેર કરીએ છીએ}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ફાઇલ શેર કરી રહ્યાં છીએ}one{# ફાઇલ શેર કરી રહ્યાં છીએ}other{# ફાઇલ શેર કરી રહ્યાં છીએ}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"શેર કરવા માટે આઇટમ પસંદ કરો"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ટેક્સ્ટ સાથે છબી શેર કરી રહ્યાં છીએ}one{ટેક્સ્ટ સાથે # છબી શેર કરી રહ્યાં છીએ}other{ટેક્સ્ટ સાથે # છબી શેર કરી રહ્યાં છીએ}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{લિંક સાથે છબી શેર કરી રહ્યાં છીએ}one{લિંક સાથે # છબી શેર કરી રહ્યાં છીએ}other{લિંક સાથે # છબી શેર કરી રહ્યાં છીએ}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ટેક્સ્ટ સાથે વીડિયો શેર કરી રહ્યાં છીએ}one{ટેક્સ્ટ સાથે # વીડિયો શેર કરી રહ્યાં છીએ}other{ટેક્સ્ટ સાથે # વીડિયો શેર કરી રહ્યાં છીએ}}"</string> diff --git a/java/res/values-hi/strings.xml b/java/res/values-hi/strings.xml index fcf484b9..3f6db1be 100644 --- a/java/res/values-hi/strings.xml +++ b/java/res/values-hi/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{इमेज शेयर की जा रही है}one{# इमेज शेयर की जा रही है}other{# इमेज शेयर की जा रही हैं}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{वीडियो शेयर किया जा रहा है}one{# वीडियो शेयर किया जा रहा है}other{# वीडियो शेयर किए जा रहे हैं}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# फ़ाइल शेयर की जा रही है}one{# फ़ाइल शेयर की जा रही है}other{# फ़ाइलें शेयर की जा रही हैं}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"शेयर करने के लिए आइटम चुनें"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{टेक्स्ट के साथ इमेज शेयर की जा रही है}one{टेक्स्ट के साथ # इमेज शेयर की जा रही है}other{टेक्स्ट के साथ # इमेज शेयर की जा रही हैं}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{लिंक के साथ इमेज शेयर की जा रही है}one{लिंक के साथ # इमेज शेयर की जा रही है}other{लिंक के साथ # इमेज शेयर की जा रही हैं}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{टेक्स्ट के साथ वीडियो शेयर किया जा रहा है}one{टेक्स्ट के साथ # वीडियो शेयर किया जा रहा है}other{टेक्स्ट के साथ # वीडियो शेयर किए जा रहे हैं}}"</string> @@ -75,7 +76,7 @@ <string name="file_preview_a11y_description" msgid="7397224827802410602">"फ़ाइल के थंबनेल की झलक"</string> <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"शेयर करने के लिए, किसी व्यक्ति का सुझाव नहीं दिया गया है"</string> <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"इस ऐप्लिकेशन को रिकॉर्ड करने की अनुमति नहीं दी गई है. हालांकि, ऐप्लिकेशन इस यूएसबी डिवाइस से ऐसा कर सकता है."</string> - <string name="resolver_personal_tab" msgid="1381052735324320565">"निजी प्रोफ़ाइल"</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> diff --git a/java/res/values-hr/strings.xml b/java/res/values-hr/strings.xml index ca62036d..85858303 100644 --- a/java/res/values-hr/strings.xml +++ b/java/res/values-hr/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Odaberite stavke za dijeljenje"</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> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Dijeli se slika s vezom}one{Dijeli se # slika s vezom}few{Dijele se # slike s vezom}other{Dijeli se # slika s vezom}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Dijeli se videozapis s tekstom}one{Dijeli se # videozapis s tekstom}few{Dijele se # videozapisa s tekstom}other{Dijeli se # videozapisa s tekstom}}"</string> diff --git a/java/res/values-hu/strings.xml b/java/res/values-hu/strings.xml index a0bce668..792b07e2 100644 --- a/java/res/values-hu/strings.xml +++ b/java/res/values-hu/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Kép megosztása}other{# kép megosztása}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Videó megosztása}other{# videó megosztása}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# fájl megosztása}other{# fájl megosztása}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Válassza ki a megosztani kívánt elemeket"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Kép megosztása szöveggel}other{# kép megosztása szöveggel}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Kép megosztása linkkel}other{# kép megosztása linkkel}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Videó megosztása szöveggel}other{# videó megosztása szöveggel}}"</string> diff --git a/java/res/values-hy/strings.xml b/java/res/values-hy/strings.xml index 2ee335da..f9232a5a 100644 --- a/java/res/values-hy/strings.xml +++ b/java/res/values-hy/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Պատկերի ուղարկում}one{# պատկերի ուղարկում}other{# պատկերի ուղարկում}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Տեսանյութի ուղարկում}one{# տեսանյութի ուղարկում}other{# տեսանյութի ուղարկում}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Ուղարկվում է # ֆայլ}one{Ուղարկվում է # ֆայլ}other{Ուղարկվում է # ֆայլ}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Ընտրեք տարրեր՝ կիսվելու համար"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Պատկերի ուղարկում տեքստային հաղորդագրության միջոցով}one{# պատկերի ուղարկում տեքստային հաղորդագրության միջոցով}other{# պատկերի ուղարկում տեքստային հաղորդագրության միջոցով}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Պատկերի ուղարկում հղման միջոցով}one{# պատկերի ուղարկում հղման միջոցով}other{# պատկերի ուղարկում հղման միջոցով}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Տեսանյութի ուղարկում տեքստային հաղորդագրության միջոցով}one{# տեսանյութի ուղարկում տեքստային հաղորդագրության միջոցով}other{# տեսանյութի ուղարկում տեքստային հաղորդագրության միջոցով}}"</string> diff --git a/java/res/values-in/strings.xml b/java/res/values-in/strings.xml index 1efaf920..df05fdd0 100644 --- a/java/res/values-in/strings.xml +++ b/java/res/values-in/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Berbagi gambar}other{Berbagi # gambar}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Membagikan video}other{Membagikan # video}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Membagikan # file}other{Membagikan # file}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Pilih item untuk dibagikan"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Membagikan gambar dengan teks}other{Membagikan # gambar dengan teks}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Membagikan gambar dengan link}other{Membagikan # gambar dengan link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Membagikan video dengan teks}other{Membagikan # video dengan teks}}"</string> diff --git a/java/res/values-is/strings.xml b/java/res/values-is/strings.xml index 9bc4f5cb..680ed17a 100644 --- a/java/res/values-is/strings.xml +++ b/java/res/values-is/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deilir mynd}one{Deilir # mynd}other{Deilir # myndum}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deilir myndskeiði}one{Deilir # myndskeiði}other{Deilir # myndskeiðum}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deilir # skrá}one{Deilir # skrá}other{Deilir # skrám}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Veldu atriði til að deila"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deilir mynd með texta}one{Deilir # mynd með texta}other{Deilir # myndum með texta}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deilir mynd með tengli}one{Deilir # mynd með tengli}other{Deilir # myndum með tengli}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deilir myndskeiði með texta}one{Deilir # myndskeiði með texta}other{Deilir # myndskeiðum með texta}}"</string> diff --git a/java/res/values-it/strings.xml b/java/res/values-it/strings.xml index 702dc48e..3762f58b 100644 --- a/java/res/values-it/strings.xml +++ b/java/res/values-it/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Condivisione dell\'immagine}many{Condivisione di # immagini}other{Condivisione di # immagini}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Condivisione del video…}many{Condivisione di # video…}other{Condivisione di # video…}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Condivisione di # file in corso…}many{Condivisione di # file in corso…}other{Condivisione di # file in corso…}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Seleziona gli elementi da condividere"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Condivisione immagine con testo in corso…}many{Condivisione # immagini con testo in corso…}other{Condivisione # immagini con testo in corso…}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Condivisione immagine con link}many{Condivisione # immagini con link}other{Condivisione # immagini con link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Condivisione video con messaggio in corso…}many{Condivisione # video con messaggio in corso…}other{Condivisione # video con messaggio in corso…}}"</string> diff --git a/java/res/values-iw/strings.xml b/java/res/values-iw/strings.xml index 7c13ebd3..bed01ff0 100644 --- a/java/res/values-iw/strings.xml +++ b/java/res/values-iw/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{שיתוף של תמונה}one{שיתוף של # תמונות}two{שיתוף של # תמונות}other{שיתוף של # תמונות}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{מתבצע שיתוף של סרטון}one{מתבצע שיתוף של # סרטונים}two{מתבצע שיתוף של # סרטונים}other{מתבצע שיתוף של # סרטונים}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{מתבצע שיתוף של קובץ אחד}one{מתבצע שיתוף של # קבצים}two{מתבצע שיתוף של # קבצים}other{מתבצע שיתוף של # קבצים}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"בחירת פריטים לשיתוף"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{שיתוף תמונה עם טקסט}one{שיתוף # תמונות עם טקסט}two{שיתוף # תמונות עם טקסט}other{שיתוף # תמונות עם טקסט}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{שיתוף תמונה עם קישור}one{שיתוף # תמונות עם קישור}two{שיתוף # תמונות עם קישור}other{שיתוף # תמונות עם קישור}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{שיתוף סרטון עם טקסט}one{שיתוף # סרטונים עם טקסט}two{שיתוף # סרטונים עם טקסט}other{שיתוף # סרטונים עם טקסט}}"</string> @@ -77,7 +78,7 @@ <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_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> diff --git a/java/res/values-ja/strings.xml b/java/res/values-ja/strings.xml index 0c97d64a..1d2a2f06 100644 --- a/java/res/values-ja/strings.xml +++ b/java/res/values-ja/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{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="select_items_to_share" msgid="1026071777275022579">"共有するアイテムの選択"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{テキスト付き画像を共有しています}other{テキスト付き画像を # 件共有しています}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{リンク付き画像を共有しています}other{リンク付き画像を # 件共有しています}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{テキスト付き動画を共有中}other{テキスト付き動画を # 件共有中}}"</string> diff --git a/java/res/values-ka/strings.xml b/java/res/values-ka/strings.xml index 46d1f1e7..4675734b 100644 --- a/java/res/values-ka/strings.xml +++ b/java/res/values-ka/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"გასაზიარებელი ერთეულების არჩევა"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{სურათი ზიარდება ტექსტით}other{# სურათი ზიარდება ტექსტით}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{სურათი ზიარდება ბმულით}other{# სურათი ზიარდება ბმულით}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ვიდეო ზიარდება ტექსტით}other{# ვიდეო ზიარდება ტექსტით}}"</string> diff --git a/java/res/values-kk/strings.xml b/java/res/values-kk/strings.xml index ee3135fa..362db640 100644 --- a/java/res/values-kk/strings.xml +++ b/java/res/values-kk/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Бөлісетін элементтерді таңдау"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Мәтіні бар сурет жіберу}other{Мәтіні бар # сурет жіберу}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Сілтемесі бар сурет жіберу}other{Сілтемесі бар # сурет жіберу}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Мәтіні бар бейне жіберу}other{Мәтіні бар # бейне жіберу}}"</string> diff --git a/java/res/values-km/strings.xml b/java/res/values-km/strings.xml index eb2ef8a0..cee11e26 100644 --- a/java/res/values-km/strings.xml +++ b/java/res/values-km/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"ជ្រើសរើសធាតុដែលត្រូវចែករំលែក"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ចែករំលែករូបភាពជាមួយអក្សរ}other{ចែករំលែករូបភាព # ជាមួយអក្សរ}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ចែករំលែករូបភាពជាមួយតំណ}other{ចែករំលែករូបភាព # ជាមួយតំណ}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ចែករំលែកវីដេអូជាមួយអក្សរ}other{ចែករំលែក # វីដេអូជាមួយអក្សរ}}"</string> diff --git a/java/res/values-kn/strings.xml b/java/res/values-kn/strings.xml index 17f3b295..35bf148c 100644 --- a/java/res/values-kn/strings.xml +++ b/java/res/values-kn/strings.xml @@ -45,8 +45,8 @@ <string name="use_a_different_app" msgid="2062380818535918975">"ಬೇರೊಂದು ಆ್ಯಪ್ ಬಳಸಿ"</string> <string name="chooseActivity" msgid="6659724877523973446">"ಕ್ರಿಯೆಯನ್ನು ಆಯ್ಕೆಮಾಡಿ"</string> <string name="noApplications" msgid="1139487441772284671">"ಯಾವುದೇ ಅಪ್ಲಿಕೇಶನ್ಗಳು ಈ ಕ್ರಿಯೆಗಾಗಿ ಬದ್ಧತೆ ತೋರಿಸುವುದಿಲ್ಲ."</string> - <string name="forward_intent_to_owner" msgid="6454987608971162379">"ನಿಮ್ಮ ಕೆಲಸದ ಪ್ರೊಫೈಲ್ನ ಹೊರಗೆ ನೀವು ಈ ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಬಳಸುತ್ತಿರುವಿರಿ"</string> - <string name="forward_intent_to_work" msgid="2906094223089139419">"ನಿಮ್ಮ ಕೆಲಸದ ಪ್ರೊಫೈಲ್ನಲ್ಲಿ ನೀವು ಈ ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಬಳಸುತ್ತಿರುವಿರಿ"</string> + <string name="forward_intent_to_owner" msgid="6454987608971162379">"ನಿಮ್ಮ ಕೆಲಸದ ಪ್ರೊಫೈಲ್ನ ಹೊರಗೆ ನೀವು ಈ ಆ್ಯಪ್ ಅನ್ನು ಬಳಸುತ್ತಿರುವಿರಿ"</string> + <string name="forward_intent_to_work" msgid="2906094223089139419">"ನಿಮ್ಮ ಕೆಲಸದ ಪ್ರೊಫೈಲ್ನಲ್ಲಿ ನೀವು ಈ ಆ್ಯಪ್ ಅನ್ನು ಬಳಸುತ್ತಿರುವಿರಿ"</string> <string name="activity_resolver_use_always" msgid="8674194687637555245">"ಯಾವಾಗಲೂ"</string> <string name="activity_resolver_use_once" msgid="594173435998892989">"ಒಮ್ಮೆ ಮಾತ್ರ"</string> <string name="activity_resolver_work_profiles_support" msgid="8228711455685203580">"<xliff:g id="APP">%1$s</xliff:g> ಉದ್ಯೋಗದ ಪ್ರೊಫೈಲ್ ಅನ್ನು ಬೆಂಬಲಿಸುವುದಿಲ್ಲ"</string> @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ಚಿತ್ರವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{# ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{# ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ವೀಡಿಯೊವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{# ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{# ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ಫೈಲ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{# ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{# ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"ಹಂಚಿಕೊಳ್ಳಲು ಐಟಂಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ಪಠ್ಯದೊಂದಿಗೆ ಚಿತ್ರವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಪಠ್ಯದೊಂದಿಗೆ # ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಪಠ್ಯದೊಂದಿಗೆ # ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ಲಿಂಕ್ನೊಂದಿಗೆ ಚಿತ್ರವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಲಿಂಕ್ನೊಂದಿಗೆ # ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಲಿಂಕ್ನೊಂದಿಗೆ # ಚಿತ್ರಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ಪಠ್ಯದೊಂದಿಗೆ ವೀಡಿಯೊವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಪಠ್ಯದೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಪಠ್ಯದೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> diff --git a/java/res/values-ko/strings.xml b/java/res/values-ko/strings.xml index b75b9bdd..094f09b0 100644 --- a/java/res/values-ko/strings.xml +++ b/java/res/values-ko/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{이미지 공유}other{이미지 #개 공유}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{동영상 1개 공유 중}other{동영상 #개 공유 중}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{파일 #개 공유 중}other{파일 #개 공유 중}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"공유할 항목 선택"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{텍스트로 이미지 공유 중}other{텍스트로 이미지 #개 공유 중}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{링크로 이미지 공유 중}other{링크로 이미지 #개 공유 중}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{텍스트로 동영상 공유 중}other{텍스트로 동영상 #개 공유 중}}"</string> diff --git a/java/res/values-ky/strings.xml b/java/res/values-ky/strings.xml index 6f84e1bf..610adaf2 100644 --- a/java/res/values-ky/strings.xml +++ b/java/res/values-ky/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Бөлүшө турган нерселерди тандаңыз"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Сүрөттү текст менен жөнөтүү}other{# cүрөттү текст менен жөнөтүү}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Сүрөттү шилтеме менен жөнөтүү}other{# сүрөттү шилтеме менен жөнөтүү}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Видеону текст менен жөнөтүү}other{# видеону текст менен жөнөтүү}}"</string> diff --git a/java/res/values-lo/strings.xml b/java/res/values-lo/strings.xml index 2a65f486..2cdea91f 100644 --- a/java/res/values-lo/strings.xml +++ b/java/res/values-lo/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"ເລືອກລາຍການທີ່ຈະແບ່ງປັນ"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ກຳລັງແບ່ງປັນຮູບພ້ອມຂໍ້ຄວາມ}other{ກຳລັງແບ່ງປັນ # ຮູບພ້ອມຂໍ້ຄວາມ}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ກຳລັງແບ່ງປັນຮູບພ້ອມລິ້ງ}other{ກຳລັງແບ່ງປັນ # ຮູບພ້ອມລິ້ງ}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ກຳລັງແບ່ງປັນວິດີໂອພ້ອມຂໍ້ຄວາມ}other{ກຳລັງແບ່ງປັນ # ວິດີໂອພ້ອມຂໍ້ຄວາມ}}"</string> diff --git a/java/res/values-lt/strings.xml b/java/res/values-lt/strings.xml index bb495311..7b0c6695 100644 --- a/java/res/values-lt/strings.xml +++ b/java/res/values-lt/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Bendrinamas vaizdas}one{Bendrinamas # vaizdas}few{Bendrinami # vaizdai}many{Bendrinama # vaizdo}other{Bendrinama # vaizdų}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Bendrinamas vaizdo įrašas}one{Bendrinamas # vaizdo įrašas}few{Bendrinami # vaizdo įrašai}many{Bendrinama # vaizdo įrašo}other{Bendrinama # vaizdo įrašų}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Bendrinamas # failas}one{Bendrinamas # failas}few{Bendrinami # failai}many{Bendrinama # failo}other{Bendrinama # failų}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Norimų bendrinti elementų pasirinkimas"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Bendrinamas vaizdas su tekstu}one{Bendrinamas # vaizdas su tekstu}few{Bendrinami # vaizdai su tekstu}many{Bendrinama # vaizdo su tekstu}other{Bendrinama # vaizdų su tekstu}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Bendrinamas vaizdas su nuoroda}one{Bendrinamas # vaizdas su nuoroda}few{Bendrinami # vaizdai su nuoroda}many{Bendrinama # vaizdo su nuoroda}other{Bendrinama # vaizdų su nuoroda}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Bendrinamas vaizdo įrašas su tekstu}one{Bendrinamas # vaizdo įrašas su tekstu}few{Bendrinami # vaizdo įrašai su tekstu}many{Bendrinama # vaizdo įrašo su tekstu}other{Bendrinama # vaizdo įrašų su tekstu}}"</string> diff --git a/java/res/values-lv/strings.xml b/java/res/values-lv/strings.xml index 7dd6cac9..1c14c2b8 100644 --- a/java/res/values-lv/strings.xml +++ b/java/res/values-lv/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Tiek kopīgots attēls}zero{Tiek kopīgoti # attēli}one{Tiek kopīgots # attēls}other{Tiek kopīgoti # attēli}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Tiek kopīgots video}zero{Tiek kopīgoti # video}one{Tiek kopīgots # video}other{Tiek kopīgoti # video}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Notiek # faila kopīgošana}zero{Notiek # failu kopīgošana}one{Notiek # faila kopīgošana}other{Notiek # failu kopīgošana}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Atlasiet kopīgojamos vienumus"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Tiek kopīgots attēls ar tekstu}zero{Tiek kopīgoti # attēli ar tekstu}one{Tiek kopīgots # attēls ar tekstu}other{Tiek kopīgoti # attēli ar tekstu}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Tiek kopīgots attēls ar saiti}zero{Tiek kopīgoti # attēli ar saitēm}one{Tiek kopīgots # attēls ar saitēm}other{Tiek kopīgoti # attēli ar saitēm}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Tiek kopīgots videoklips ar tekstu}zero{Tiek kopīgoti # videoklipi ar tekstu}one{Tiek kopīgots # videoklips ar tekstu}other{Tiek kopīgoti # videoklipi ar tekstu}}"</string> diff --git a/java/res/values-mk/strings.xml b/java/res/values-mk/strings.xml index 45fb82e3..19ff3c67 100644 --- a/java/res/values-mk/strings.xml +++ b/java/res/values-mk/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Споделување слика}one{Споделување # слика}other{Споделување # слики}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Се споделува видео}one{Се споделува # видео}other{Се споделуваат # видеа}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Се споделува # датотека}one{Се споделуваат # датотека}other{Се споделуваат # датотеки}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Изберете ставки за споделување"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Се споделува слика со SMS}one{Се споделуваат # слика со SMS}other{Се споделуваат # слики со SMS}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Се споделува слика со линк}one{Се споделуваат # слика со линк}other{Се споделуваат # слики со линк}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Се споделува видео со SMS}one{Се споделуваат # видео со SMS}other{Се споделуваат # видеа со SMS}}"</string> diff --git a/java/res/values-ml/strings.xml b/java/res/values-ml/strings.xml index ce466e8f..bcd07dd7 100644 --- a/java/res/values-ml/strings.xml +++ b/java/res/values-ml/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"പങ്കിടാൻ ഇനങ്ങൾ തിരഞ്ഞെടുക്കുക"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ടെക്സ്റ്റിനൊപ്പം ചിത്രം പങ്കിടുന്നു}other{ടെക്സ്റ്റിനൊപ്പം # ചിത്രം പങ്കിടുന്നു}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ലിങ്കിനൊപ്പം ചിത്രം പങ്കിടുന്നു}other{ലിങ്കിനൊപ്പം # ചിത്രങ്ങൾ പങ്കിടുന്നു}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ടെക്സ്റ്റിനൊപ്പം വീഡിയോ പങ്കിടുന്നു}other{ടെക്സ്റ്റിനൊപ്പം # വീഡിയോകൾ പങ്കിടുന്നു}}"</string> diff --git a/java/res/values-mn/strings.xml b/java/res/values-mn/strings.xml index 30686c51..81d97d99 100644 --- a/java/res/values-mn/strings.xml +++ b/java/res/values-mn/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Хуваалцах зүйлс сонгох"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Тексттэй зураг хуваалцаж байна}other{Тексттэй # зураг хуваалцаж байна}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Холбоостой зураг хуваалцаж байна}other{Холбоостой # зураг хуваалцаж байна}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Тексттэй видео хуваалцаж байна}other{Тексттэй # видео хуваалцаж байна}}"</string> diff --git a/java/res/values-mr/strings.xml b/java/res/values-mr/strings.xml index 9ad4a4c8..4a061601 100644 --- a/java/res/values-mr/strings.xml +++ b/java/res/values-mr/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"शेअर करण्यासाठी आयटम निवडा"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{मजकुरासह इमेज शेअर करत आहे}other{मजकुरासह # इमेज शेअर करत आहे}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{लिंकसह इमेज शेअर करत आहे}other{लिंकसह # इमेज शेअर करत आहे}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{मजकुरासह व्हिडिओ शेअर करत आहे}other{मजकुरासह # व्हिडिओ शेअर करत आहे}}"</string> diff --git a/java/res/values-ms/strings.xml b/java/res/values-ms/strings.xml index 92e7a26f..a01376c6 100644 --- a/java/res/values-ms/strings.xml +++ b/java/res/values-ms/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Berkongsi imej}other{Berkongsi # imej}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Berkongsi video}other{Berkongsi # video}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Berkongsi # fail}other{Berkongsi # fail}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Pilih item untuk dikongsi"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Berkongsi imej dengan teks}other{Berkongsi # imej dengan teks}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Berkongsi imej dengan pautan}other{Berkongsi # imej dengan pautan}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Berkongsi video dengan teks}other{Berkongsi # video dengan teks}}"</string> diff --git a/java/res/values-my/strings.xml b/java/res/values-my/strings.xml index 1f78c7f1..9eeda078 100644 --- a/java/res/values-my/strings.xml +++ b/java/res/values-my/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"မျှဝေမည့်အရာများ ရွေးခြင်း"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{စာသားပါသောပုံကို မျှဝေနေသည်}other{စာသားပါသောပုံ # ပုံကို မျှဝေနေသည်}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{လင့်ခ်ပါသောပုံကို မျှဝေနေသည်}other{လင့်ခ်ပါသောပုံ # ပုံကို မျှဝေနေသည်}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{စာသားပါသောဗီဒီယိုကို မျှဝေနေသည်}other{စာသားပါသောဗီဒီယို # ခုကို မျှဝေနေသည်}}"</string> diff --git a/java/res/values-nb/strings.xml b/java/res/values-nb/strings.xml index f9b91f7a..7a67bc34 100644 --- a/java/res/values-nb/strings.xml +++ b/java/res/values-nb/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deler bildet}other{Deler # bilder}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deler videoen}other{Deler # videoer}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deler # fil}other{Deler # filer}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Velg elementene du vil dele"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deler bildet med tekst}other{Deler # bilder med tekst}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deler bildet med link}other{Deler # bilder med link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deler videoen med tekst}other{Deler # videoer med tekst}}"</string> diff --git a/java/res/values-ne/strings.xml b/java/res/values-ne/strings.xml index 61c7fe17..76365455 100644 --- a/java/res/values-ne/strings.xml +++ b/java/res/values-ne/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"आफूले सेयर गर्न चाहेका सामग्री चयन गर्नुहोस्"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{टेक्स्ट भएको फोटो सेयर गरिँदै छ}other{टेक्स्ट भएका # वटा फोटो सेयर गरिँदै छन्}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{लिंक भएको फोटो सेयर गरिँदै छ}other{लिंक भएका # वटा फोटो सेयर गरिँदै छन्}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{टेक्स्ट भएको भिडियो सेयर गरिँदै छ}other{टेक्स्ट भएका # वटा भिडियो सेयर गरिँदै छन्}}"</string> diff --git a/java/res/values-nl/strings.xml b/java/res/values-nl/strings.xml index a259a205..e452e98e 100644 --- a/java/res/values-nl/strings.xml +++ b/java/res/values-nl/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Afbeelding delen}other{# afbeeldingen delen}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video delen}other{# video\'s delen}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# bestand delen}other{# bestanden delen}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Items selecteren om te delen"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Afbeelding met tekst wordt gedeeld}other{# afbeeldingen met tekst worden gedeeld}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Afbeelding delen via link}other{# afbeeldingen delen via link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Video delen via tekstbericht}other{# video\'s delen via tekstbericht}}"</string> diff --git a/java/res/values-or/strings.xml b/java/res/values-or/strings.xml index 99e476e7..0e2ece56 100644 --- a/java/res/values-or/strings.xml +++ b/java/res/values-or/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"ସେୟାର କରିବା ପାଇଁ ଆଇଟମଗୁଡ଼ିକ ଚୟନ କରନ୍ତୁ"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ଟେକ୍ସଟ ସହ ଇମେଜ ସେୟାର କରାଯାଉଛି}other{ଟେକ୍ସଟ ସହ #ଟି ଇମେଜ ସେୟାର କରାଯାଉଛି}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ଲିଙ୍କ ସହ ଇମେଜ ସେୟାର କରାଯାଉଛି}other{ଲିଙ୍କ ସହ #ଟି ଇମେଜ ସେୟାର କରାଯାଉଛି}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ଟେକ୍ସଟ ସହ ଭିଡିଓ ସେୟାର କରାଯାଉଛି}other{ଟେକ୍ସଟ ସହ #ଟି ଭିଡିଓ ସେୟାର କରାଯାଉଛି}}"</string> diff --git a/java/res/values-pa/strings.xml b/java/res/values-pa/strings.xml index 04565373..607f7d26 100644 --- a/java/res/values-pa/strings.xml +++ b/java/res/values-pa/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{ਚਿੱਤਰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{# ਚਿੱਤਰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{# ਚਿੱਤਰ ਸਾਂਝੇ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{# ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{# ਵੀਡੀਓ ਸਾਂਝੇ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ਫ਼ਾਈਲ ਸਾਂਝੀ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ}one{# ਫ਼ਾਈਲ ਸਾਂਝੀ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ}other{# ਫ਼ਾਈਲਾਂ ਸਾਂਝੀਆਂ ਕੀਤੀਆਂ ਜਾ ਰਹੀਆਂ ਹਨ}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"ਸਾਂਝਾ ਕਰਨ ਲਈ ਆਈਟਮਾਂ ਚੁਣੋ"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ ਚਿੱਤਰ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਚਿੱਤਰ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਚਿੱਤਰਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{ਲਿੰਕ ਨਾਲ ਚਿੱਤਰ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿੰਕ ਨਾਲ # ਚਿੱਤਰ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿੰਕ ਨਾਲ # ਚਿੱਤਰਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝੇ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ}}"</string> diff --git a/java/res/values-pl/strings.xml b/java/res/values-pl/strings.xml index e67510e3..10dda621 100644 --- a/java/res/values-pl/strings.xml +++ b/java/res/values-pl/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Wybierz elementy do udostępnienia"</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> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Udostępnianie obrazu przez link}few{Udostępnianie # obrazów przez link}many{Udostępnianie # obrazów przez link}other{Udostępnianie # obrazu przez link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Udostępnianie filmu przez SMS}few{Udostępnianie # filmów przez SMS}many{Udostępnianie # filmów przez SMS}other{Udostępnianie # filmu przez SMS}}"</string> diff --git a/java/res/values-pt-rBR/strings.xml b/java/res/values-pt-rBR/strings.xml index b5778cf6..c8ce55a8 100644 --- a/java/res/values-pt-rBR/strings.xml +++ b/java/res/values-pt-rBR/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Selecione os itens para compartilhar"</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> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartilhando imagem com link}one{Compartilhando # imagem com link}many{Compartilhando # de imagens com link}other{Compartilhando # imagens com link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartilhando vídeo com texto}one{Compartilhando # vídeo com texto}many{Compartilhando # de vídeos com texto}other{Compartilhando # vídeos com texto}}"</string> diff --git a/java/res/values-pt-rPT/strings.xml b/java/res/values-pt-rPT/strings.xml index 0abb79be..ffcf9a1e 100644 --- a/java/res/values-pt-rPT/strings.xml +++ b/java/res/values-pt-rPT/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Partilhar imagem}many{Partilhar # imagens}other{Partilhar # imagens}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{A partilhar vídeo}many{A partilhar # vídeos}other{A partilhar # vídeos}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{A partilhar # ficheiro}many{A partilhar # ficheiros}other{A partilhar # ficheiros}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Selecione itens para partilhar"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{A partilhar imagem com texto}many{A partilhar # imagens com texto}other{A partilhar # imagens com texto}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{A partilhar imagem com link}many{A partilhar # imagens com link}other{A partilhar # imagens com link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{A partilhar vídeo com texto}many{A partilhar # vídeos com texto}other{A partilhar # vídeos com texto}}"</string> diff --git a/java/res/values-pt/strings.xml b/java/res/values-pt/strings.xml index b5778cf6..c8ce55a8 100644 --- a/java/res/values-pt/strings.xml +++ b/java/res/values-pt/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Selecione os itens para compartilhar"</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> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Compartilhando imagem com link}one{Compartilhando # imagem com link}many{Compartilhando # de imagens com link}other{Compartilhando # imagens com link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Compartilhando vídeo com texto}one{Compartilhando # vídeo com texto}many{Compartilhando # de vídeos com texto}other{Compartilhando # vídeos com texto}}"</string> diff --git a/java/res/values-ro/strings.xml b/java/res/values-ro/strings.xml index 02d5df12..c2843bab 100644 --- a/java/res/values-ro/strings.xml +++ b/java/res/values-ro/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Se trimite imaginea}few{Se trimit # imagini}other{Se trimit # de imagini}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Se trimite videoclipul}few{Se trimit # videoclipuri}other{Se trimit # de videoclipuri}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Se trimite un fișier}few{Se trimit # fișiere}other{Se trimit # de fișiere}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Selectează articole de trimis"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Se trimite imaginea cu text}few{Se trimit # imagini cu text}other{Se trimit # de imagini cu text}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Se trimite imaginea cu linkul}few{Se trimit # imagini cu linkul}other{Se trimit # de imagini cu linkul}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Se trimite videoclipul cu text}few{Se trimit # videoclipuri cu text}other{Se trimit # de videoclipuri cu text}}"</string> diff --git a/java/res/values-ru/strings.xml b/java/res/values-ru/strings.xml index fa8a06a3..9b4c2d20 100644 --- a/java/res/values-ru/strings.xml +++ b/java/res/values-ru/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Отправка изображения}one{Отправка # изображения}few{Отправка # изображений}many{Отправка # изображений}other{Отправка # изображения}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Отправка видео}one{Отправка # видео}few{Отправка # видео}many{Отправка # видео}other{Отправка # видео}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Предоставляется доступ к # файлу}one{Предоставляется доступ к # файлу}few{Предоставляется доступ к # файлам}many{Предоставляется доступ к # файлам}other{Предоставляется доступ к # файла}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Выберите объекты для отправки"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Отправка изображения с текстом}one{Отправка # изображения с текстом}few{Отправка # изображений с текстом}many{Отправка # изображений с текстом}other{Отправка # изображения с текстом}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Отправка изображения со ссылкой}one{Отправка # изображения со ссылкой}few{Отправка # изображений со ссылкой}many{Отправка # изображений со ссылкой}other{Отправка # изображения со ссылкой}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Отправка видео с текстом}one{Отправка # видео с текстом}few{Отправка # видео с текстом}many{Отправка # видео с текстом}other{Отправка # видео с текстом}}"</string> diff --git a/java/res/values-si/strings.xml b/java/res/values-si/strings.xml index 6f5be5f5..1fc87e4d 100644 --- a/java/res/values-si/strings.xml +++ b/java/res/values-si/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{රූපය බෙදා ගැනීම}one{රූප #ක් බෙදා ගැනීම}other{රූප #ක් බෙදා ගැනීම}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{වීඩියෝව බෙදා ගැනීම}one{වීඩියෝ #ක් බෙදා ගැනීම}other{වීඩියෝ #ක් බෙදා ගැනීම}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ගොනුවක් බෙදා ගැනීම}one{ගොනු #ක් බෙදා ගැනීම}other{ගොනු #ක් බෙදා ගැනීම}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"බෙදා ගැනීමට අයිතම තෝරන්න"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{පෙළ සමග රූපය බෙදා ගැනීම}one{පෙළ සමග රූප #ක් බෙදා ගැනීම}other{පෙළ සමග රූප #ක් බෙදා ගැනීම}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{සබැඳිය සමග රූපය බෙදා ගැනීම}one{සබැඳිය සමග රූප #ක් බෙදා ගැනීම}other{සබැඳිය සමග රූප #ක් බෙදා ගැනීම}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{පෙළ සමග වීඩියෝව බෙදා ගැනීම}one{පෙළ සමග වීඩියෝ #ක් බෙදා ගැනීම}other{පෙළ සමග වීඩියෝ #ක් බෙදා ගැනීම}}"</string> diff --git a/java/res/values-sk/strings.xml b/java/res/values-sk/strings.xml index 926d9d50..9119aaa0 100644 --- a/java/res/values-sk/strings.xml +++ b/java/res/values-sk/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Vyberte položky na zdieľanie"</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> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Zdieľa sa obrázok s odkazom}few{Zdieľajú sa # obrázky s odkazom}many{Sharing # images with link}other{Zdieľa sa # obrázkov s odkazom}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Zdieľa sa video s textom}few{Zdieľajú sa # videá s textom}many{Sharing # videos with text}other{Zdieľa sa # videí s textom}}"</string> diff --git a/java/res/values-sl/strings.xml b/java/res/values-sl/strings.xml index afa61945..78e07ad1 100644 --- a/java/res/values-sl/strings.xml +++ b/java/res/values-sl/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Deljenje slike}one{Deljenje # slike}two{Deljenje # slik}few{Deljenje # slik}other{Deljenje # slik}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Deljenje videoposnetka}one{Deljenje # videoposnetka}two{Deljenje # videoposnetkov}few{Deljenje # videoposnetkov}other{Deljenje # videoposnetkov}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Deljenje # datoteke}one{Deljenje # datoteke}two{Deljenje # datotek}few{Deljenje # datotek}other{Deljenje # datotek}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Izbira elementov za deljenje"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Deljenje slike z besedilom}one{Deljenje # slike z besedilom}two{Deljenje # slik z besedilom}few{Deljenje # slik z besedilom}other{Deljenje # slik z besedilom}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Deljenje slike s povezavo}one{Deljenje # slike s povezavo}two{Deljenje # slik s povezavo}few{Deljenje # slik s povezavo}other{Deljenje # slik s povezavo}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Deljenje videoposnetka z besedilom}one{Deljenje # videoposnetka z besedilom}two{Deljenje # videoposnetkov z besedilom}few{Deljenje # videoposnetkov z besedilom}other{Deljenje # videoposnetkov z besedilom}}"</string> diff --git a/java/res/values-sq/strings.xml b/java/res/values-sq/strings.xml index faf27da5..374b2e0a 100644 --- a/java/res/values-sq/strings.xml +++ b/java/res/values-sq/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Po ndahet imazh}other{Po ndahen # imazhe}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Po ndahet videoja}other{Po ndahen # video}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Po ndahet # skedar}other{Po ndahen # skedarë}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Zgjidh artikujt për t\'i ndarë"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Po ndahet një imazh me tekst}other{Po ndahen # imazhe me tekst}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Po ndahet një imazh me lidhje}other{Po ndahen # imazhe me lidhje}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Po ndahet një video me tekst}other{Po ndahen # video me tekst}}"</string> diff --git a/java/res/values-sr/strings.xml b/java/res/values-sr/strings.xml index 1a9834d9..8e7c57d1 100644 --- a/java/res/values-sr/strings.xml +++ b/java/res/values-sr/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Изаберите ставке за дељење"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Дели се слика са текстом}one{Дели се # слика са текстом}few{Деле се # слике са текстом}other{Дели се # слика са текстом}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Дели се слика са линком}one{Дели се # слика са линком}few{Деле се # слике са линком}other{Дели се # слика са линком}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Дели се видео са текстом}one{Дели се # видео са текстом}few{Деле се # видео снимка са текстом}other{Дели се # видеа са текстом}}"</string> diff --git a/java/res/values-sv/strings.xml b/java/res/values-sv/strings.xml index c20b2a43..d48cc781 100644 --- a/java/res/values-sv/strings.xml +++ b/java/res/values-sv/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Delar bild}other{Delar # bilder}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Delar video}other{Delar # videor}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Delar # fil}other{Delar # filer}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Välj objekt att dela"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Delar bild med text}other{Delar # bilder med text}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Delar bild med länk}other{Delar # bilder med länk}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Delar video med text}other{Delar # videor med text}}"</string> @@ -75,7 +76,7 @@ <string name="file_preview_a11y_description" msgid="7397224827802410602">"Miniatyr av förhandsgranskning av fil"</string> <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Inga rekommenderade personer att dela med"</string> <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_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 vy"</string> diff --git a/java/res/values-sw/strings.xml b/java/res/values-sw/strings.xml index 3f99f9e7..2f63e887 100644 --- a/java/res/values-sw/strings.xml +++ b/java/res/values-sw/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Inashiriki picha}other{Inashiriki picha #}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Inashiriki video}other{Inashiriki video #}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Inashiriki faili #}other{Inashiriki faili #}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Chagua vipengee vya kutuma"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Inashiriki picha na maandishi}other{Inashiriki picha # na maandishi}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Inashiriki picha na kiungo}other{Inashiriki picha # na kiungo}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Inashiriki video na maandishi}other{Inashiriki video # na maandishi}}"</string> diff --git a/java/res/values-ta/strings.xml b/java/res/values-ta/strings.xml index f2fbb6e3..f1df5cba 100644 --- a/java/res/values-ta/strings.xml +++ b/java/res/values-ta/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"பகிர விரும்புபவற்றைத் தேர்ந்தெடுத்தல்"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{வார்த்தைகளுடன் படத்தைப் பகிர்கிறது}other{வார்த்தைகளுடன் # படங்களைப் பகிர்கிறது}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{இணைப்பைக் கொண்ட படத்தைப் பகிர்கிறது}other{இணைப்பைக் கொண்ட # படங்களைப் பகிர்கிறது}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{வார்த்தைகளைக் கொண்ட வீடியோவைப் பகிர்கிறது}other{வார்த்தைகளைக் கொண்ட # வீடியோக்களைப் பகிர்கிறது}}"</string> diff --git a/java/res/values-te/strings.xml b/java/res/values-te/strings.xml index 840279f3..b88d7d4e 100644 --- a/java/res/values-te/strings.xml +++ b/java/res/values-te/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"షేర్ చేయడానికి ఐటెమ్లను ఎంచుకోండి"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా ఇమేజ్ను షేర్ చేయడం}other{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా # ఇమేజ్లను షేర్ చేయడం}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{లింక్ చేయడం ద్వారా ఇమేజ్ను షేర్ చేయడం}other{లింక్ చేయడం ద్వారా # ఇమేజ్లను షేర్ చేయడం}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా వీడియోను షేర్ చేయడం}other{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా # వీడియోలను షేర్ చేయడం}}"</string> diff --git a/java/res/values-th/strings.xml b/java/res/values-th/strings.xml index 29a97978..5effd16c 100644 --- a/java/res/values-th/strings.xml +++ b/java/res/values-th/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"เลือกรายการที่จะแชร์"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{กำลังแชร์รูปภาพพร้อมข้อความ}other{กำลังแชร์รูปภาพ # รายการพร้อมข้อความ}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{กำลังแชร์รูปภาพพร้อมลิงก์}other{กำลังแชร์รูปภาพ # รายการพร้อมลิงก์}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{กำลังแชร์วิดีโอพร้อมข้อความ}other{กำลังแชร์วิดีโอ # รายการพร้อมข้อความ}}"</string> diff --git a/java/res/values-tl/strings.xml b/java/res/values-tl/strings.xml index b085b46b..67782253 100644 --- a/java/res/values-tl/strings.xml +++ b/java/res/values-tl/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"Pumili ng mga item na ibabahagi"</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> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Nagbabahagi ng larawang may link}one{Nagbabahagi ng # larawang may link}other{Nagbabahagi ng # na larawang may link}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Nagbabahagi ng video na may text}one{Nagbabahagi ng # video na may text}other{Nagbabahagi ng # na video na may text}}"</string> diff --git a/java/res/values-tr/strings.xml b/java/res/values-tr/strings.xml index 22024818..5dee9296 100644 --- a/java/res/values-tr/strings.xml +++ b/java/res/values-tr/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Resim paylaşılıyor}other{# resim paylaşılıyor}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video paylaşılıyor}other{# video paylaşılıyor}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# dosya paylaşılıyor}other{# dosya paylaşılıyor}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Paylaşılacak öğeleri seçin"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Metin ekli resim paylaşılıyor}other{Metin ekli # resim paylaşılıyor}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Bağlantı ekli resim paylaşılıyor}other{Bağlantı ekli # resim paylaşılıyor}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Metin ekli video paylaşılıyor}other{Metin ekli # video paylaşılıyor}}"</string> diff --git a/java/res/values-uk/strings.xml b/java/res/values-uk/strings.xml index b5f91741..293696fd 100644 --- a/java/res/values-uk/strings.xml +++ b/java/res/values-uk/strings.xml @@ -57,9 +57,10 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{і ще # файл}one{і ще # файл}few{і ще # файли}many{і ще # файлів}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{Надсилаються # зображення}many{Надсилаються # зображень}other{Надсилається # зображення}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Надсилання зображення}one{Надсилання # зображення}few{Надсилання # зображень}many{Надсилання # зображень}other{Надсилання # зображення}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Надсилається відео}one{Надсилається # відео}few{Надсилаються # відео}many{Надсилаються # відео}other{Надсилається # відео}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Надсилається # файл}one{Надсилається # файл}few{Надсилаються # файли}many{Надсилаються # файлів}other{Надсилається # файлу}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Виберіть об’єкти, якими хочете поділитися"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Надсилання зображення з текстом}one{Надсилання # зображення з текстом}few{Надсилання # зображень із текстом}many{Надсилання # зображень із текстом}other{Надсилання # зображення з текстом}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Надсилання зображення з посиланням}one{Надсилання # зображення з посиланням}few{Надсилання # зображень із посиланням}many{Надсилання # зображень із посиланням}other{Надсилання # зображення з посиланням}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Надсилання відео з текстом}one{Надсилання # відео з текстом}few{Надсилання # відео з текстом}many{Надсилання # відео з текстом}other{Надсилання # відео з текстом}}"</string> diff --git a/java/res/values-ur/strings.xml b/java/res/values-ur/strings.xml index f6eb8612..9ecc8443 100644 --- a/java/res/values-ur/strings.xml +++ b/java/res/values-ur/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"اشتراک کرنے کے لیے آئٹمز منتخب کریں"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{ٹیکسٹ کے ساتھ تصویر کا اشتراک کیا جا رہا ہے}other{ٹیکسٹ کے ساتھ # تصاویر کا اشتراک کیا جا رہا ہے}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{لنک کے ساتھ تصویر کا اشتراک کیا جا رہا ہے}other{لنک کے ساتھ # تصاویر کا اشتراک کیا جا رہا ہے}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{ٹیکسٹ کے ساتھ ویڈیو کا اشتراک کیا جا رہا ہے}other{ٹیکسٹ کے ساتھ # ویڈیوز کا اشتراک کیا جا رہا ہے}}"</string> diff --git a/java/res/values-uz/strings.xml b/java/res/values-uz/strings.xml index 96439147..f9434b18 100644 --- a/java/res/values-uz/strings.xml +++ b/java/res/values-uz/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Rasm ulashilmoqda}other{# ta rasm ulashilmoqda}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Video ulashilmoqda}other{# ta video ulashilmoqda}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# ta fayl ulashilmoqda}other{# ta fayl ulashilmoqda}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Ulashish uchun elementlarni tanlang"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Matnli havolani yuborish}other{# ta matnli havolani yuborish}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Havolali rasmni yuborish}other{# ta havolali rasmni yuborish}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Matnli videoni yuborish}other{# ta matnli videoni yuborish}}"</string> diff --git a/java/res/values-vi/strings.xml b/java/res/values-vi/strings.xml index 0645d052..4c84256e 100644 --- a/java/res/values-vi/strings.xml +++ b/java/res/values-vi/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Chia sẻ hình ảnh}other{Chia sẻ # hình ảnh}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Đang chia sẻ video}other{Đang chia sẻ # video}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Đang chia sẻ # tệp}other{Đang chia sẻ # tệp}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Chọn mục muốn chia sẻ"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Đang chia sẻ hình ảnh có văn bản}other{Đang chia sẻ # hình ảnh có văn bản}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Đang chia sẻ hình ảnh có đường liên kết}other{Đang chia sẻ # hình ảnh có đường liên kết}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Đang chia sẻ video có văn bản}other{Đang chia sẻ # video có văn bản}}"</string> @@ -88,7 +89,7 @@ <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bạn không thể mở nội dung này bằng ứng dụng cá nhân"</string> <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Không chia sẻ được nội dung này bằng ứng dụng riêng tư"</string> <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Không mở được nội dung này bằng ứng dụng riêng tư"</string> - <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Các ứng dụng công việc đã bị tạm dừng"</string> + <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Các ứng dụng trong hồ sơ Công việc đã bị tạm dừng"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Tiếp tục"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Không có ứng dụng công việc"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Không có ứng dụng cá nhân"</string> diff --git a/java/res/values-zh-rCN/strings.xml b/java/res/values-zh-rCN/strings.xml index 9fea3097..c2fa444f 100644 --- a/java/res/values-zh-rCN/strings.xml +++ b/java/res/values-zh-rCN/strings.xml @@ -60,13 +60,14 @@ <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="select_items_to_share" msgid="1026071777275022579">"选择要分享的内容"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{正在分享带有文本的图片}other{正在分享带有文本的 # 个图片}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{正在分享带有链接的图片}other{正在分享带有链接的 # 个图片}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{正在分享带有文本的视频}other{正在分享带有文本的 # 个视频}}"</string> <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_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> diff --git a/java/res/values-zh-rHK/strings.xml b/java/res/values-zh-rHK/strings.xml index 65f73d0a..54a61c7e 100644 --- a/java/res/values-zh-rHK/strings.xml +++ b/java/res/values-zh-rHK/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"選取要分享的項目"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{正在分享圖片 (含有文字)}other{正在分享 # 張圖片 (含有文字)}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{正在分享圖片 (含有連結)}other{正在分享 # 張圖片 (含有連結)}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{正在分享影片 (含有文字)}other{正在分享 # 部影片 (含有文字)}}"</string> diff --git a/java/res/values-zh-rTW/strings.xml b/java/res/values-zh-rTW/strings.xml index bade791a..0d369318 100644 --- a/java/res/values-zh-rTW/strings.xml +++ b/java/res/values-zh-rTW/strings.xml @@ -60,6 +60,7 @@ <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="select_items_to_share" msgid="1026071777275022579">"選取要分享的項目"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{分享含有文字的圖片}other{分享 # 張含有文字的圖片}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{分享含有連結的圖片}other{分享 # 張含有連結的圖片}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{分享含有文字的影片}other{分享 # 部含有文字的影片}}"</string> diff --git a/java/res/values-zu/strings.xml b/java/res/values-zu/strings.xml index 38e62f88..9d6d13dc 100644 --- a/java/res/values-zu/strings.xml +++ b/java/res/values-zu/strings.xml @@ -60,6 +60,7 @@ <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Yabelana ngomfanekiso}one{Yabelana ngemifanekiso engu-#}other{Yabelana ngemifanekiso engu-#}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Yabelana ngevidiyo}one{Yabelana ngamavidiyo angu-#}other{Yabelana ngamavidiyo angu-#}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Yabelana ngefayela eli-#}one{Yabelana ngamafayela angu-#}other{Yabelana ngamafayela angu-#}}"</string> + <string name="select_items_to_share" msgid="1026071777275022579">"Khetha izinto ongabelana ngazo"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Yabelana ngomfanekiso ngombhalo}one{Yabelana ngemifanekiso engu-# ngombhalo}other{Yabelana ngemifanekiso engu-# ngombhalo}}"</string> <string name="sharing_images_with_link" msgid="8907893266387877733">"{count,plural, =1{Yabelana ngomfanekiso ngelinki}one{Yabelana ngemifanekiso engu-# ngelinki}other{Yabelana ngemifanekiso engu-# ngelinki}}"</string> <string name="sharing_videos_with_text" msgid="4169898442482118146">"{count,plural, =1{Yabelana ngevidiyo ngombhalo}one{Yabelana ngamavidiyo angu-# ngombhalo}other{Yabelana ngamavidiyo angu-# ngombhalo}}"</string> diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index c026ee59..4f77d248 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -162,6 +162,9 @@ } </string> + <!-- Title atop a sharing UI indicating that a selection needs to be made for sharing --> + <string name="select_items_to_share">Select items to share</string> + <!-- Title atop a sharing UI indicating that some number of images are being shared along with text [CHAR_LIMIT=50] --> <string name="sharing_images_with_text">{count, plural, diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index cc7091e4..21ca3b73 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -133,8 +133,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ActionActivityStarter activityStarter, @Nullable ShareResultSender shareResultSender, Consumer</* @Nullable */ Integer> finishCallback, - ClipboardManager clipboardManager, - FeatureFlags featureFlags) { + ClipboardManager clipboardManager) { this( context, makeCopyButtonRunnable( @@ -150,8 +149,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio imageEditor), firstVisibleImageQuery, activityStarter, - log, - featureFlags.fixPartialImageEditTransition()), + log), chooserActions, onUpdateSharedTextIsExcluded, log, @@ -340,8 +338,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable TargetInfo editSharingTarget, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, - EventLog log, - boolean requireFullVisibility) { + EventLog log) { if (editSharingTarget == null) return null; return () -> { // Log share completion via edit. @@ -352,8 +349,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio firstImageView = firstVisibleImageQuery.call(); } catch (Exception e) { /* ignore */ } // Action bar is user-independent; always start as primary. - if (firstImageView == null - || (requireFullVisibility && !isFullyVisible(firstImageView))) { + if (firstImageView == null || !isFullyVisible(firstImageView)) { activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); } else { activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index a5516fde..c8387c4e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -23,10 +23,13 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static androidx.lifecycle.LifecycleKt.getCoroutineScope; import static com.android.intentresolver.ChooserActionFactory.EDIT_SOURCE; -import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.Flags.fixShortcutsFlashing; +import static com.android.intentresolver.Flags.keyboardNavigationFix; +import static com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra; +import static com.android.intentresolver.Flags.unselectFinalItem; +import static com.android.intentresolver.ext.CreationExtrasExtKt.replaceDefaultArgs; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK; -import static com.android.intentresolver.ui.model.ActivityModel.ACTIVITY_MODEL_KEY; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; import static java.util.Objects.requireNonNull; @@ -96,11 +99,10 @@ import com.android.intentresolver.ChooserRefinementManager.RefinementType; 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.PreviewViewModel; import com.android.intentresolver.data.model.ChooserRequest; +import com.android.intentresolver.data.repository.ActivityModelRepository; import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; @@ -126,6 +128,7 @@ import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType; import com.android.intentresolver.profiles.OnProfileSelectedListener; import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.ActivityModel; import com.android.intentresolver.shared.model.Profile; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; @@ -133,9 +136,9 @@ import com.android.intentresolver.ui.ActionTitle; import com.android.intentresolver.ui.ProfilePagerResources; import com.android.intentresolver.ui.ShareResultSender; import com.android.intentresolver.ui.ShareResultSenderFactory; -import com.android.intentresolver.ui.model.ActivityModel; import com.android.intentresolver.ui.viewmodel.ChooserViewModel; import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ChooserNestedScrollView; import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; @@ -148,14 +151,14 @@ import com.google.common.collect.ImmutableList; import dagger.hilt.android.AndroidEntryPoint; -import kotlin.Pair; - import kotlinx.coroutines.CoroutineDispatcher; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -206,7 +209,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements 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"; + private static final String LAST_SHOWN_PROFILE = "last_shown_tab_key"; public static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; private int mLayoutId; @@ -270,6 +273,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public ClipboardManager mClipboardManager; @Inject public IntentForwarding mIntentForwarding; @Inject public ShareResultSenderFactory mShareResultSenderFactory; + @Inject public ActivityModelRepository mActivityModelRepository; private ActivityModel mActivityModel; private ChooserRequest mRequest; @@ -306,7 +310,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); - private final Map<Integer, ProfileRecord> mProfileRecords = new HashMap<>(); + private final Map<Integer, ProfileRecord> mProfileRecords = new LinkedHashMap<>(); private boolean mExcludeSharedText = false; /** @@ -328,15 +332,18 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @NonNull @Override public CreationExtras getDefaultViewModelCreationExtras() { - return addDefaultArgs( - super.getDefaultViewModelCreationExtras(), - new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel())); + // DEFAULT_ARGS_KEY extra is saved for each ViewModel we create. ComponentActivity puts the + // initial intent's extra into DEFAULT_ARGS_KEY thus we store these values 2 times (3 if we + // count the initial intent). We don't need those values to be saved as they don't capture + // the state. + return replaceDefaultArgs(super.getDefaultViewModelCreationExtras()); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.i(TAG, "onCreate"); + mActivityModelRepository.initialize(this::createActivityModel); mTargetDataLoader = mChooserServiceFeatureFlags.chooserPayloadToggling() ? mCachingTargetDataLoaderProvider.get() @@ -349,8 +356,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); mChooserHelper.setOnPendingSelection(this::onPendingSelection); + if (unselectFinalItem()) { + mChooserHelper.setOnHasSelections(this::onHasSelections); + } } } + private int mInitialProfile = -1; @Override protected final void onStart() { @@ -412,7 +423,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected final void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (mViewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, mViewPager.getCurrentItem()); + outState.putInt( + LAST_SHOWN_PROFILE, mChooserMultiProfilePagerAdapter.getActiveProfile()); } } @@ -517,6 +529,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mProfilePagerResources, mRequest, mProfiles, + mProfileRecords.values(), mProfileAvailability, mRequest.getInitialIntents(), mMaxTargetsPerRow); @@ -633,21 +646,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements finish(); } }); - BasePreviewViewModel previewViewModel = - new ViewModelProvider(this, createPreviewViewModelFactory()) - .get(BasePreviewViewModel.class); - previewViewModel.init( - mRequest.getTargetIntent(), - mRequest.getAdditionalContentUri(), - mChooserServiceFeatureFlags.chooserPayloadToggling()); ChooserContentPreviewUi.ActionFactory actionFactory = decorateActionFactoryWithRefinement( createChooserActionFactory(mRequest.getTargetIntent())); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.getPreviewDataProvider(), - mRequest.getTargetIntent(), - previewViewModel.getImageLoader(), + mViewModel.getPreviewDataProvider(), + mRequest, + mViewModel.getImageLoader(), actionFactory, createModifyShareActionFactory(), mEnterTransitionAnimationDelegate, @@ -688,6 +694,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getModifyShareAction() != null ); mEnterTransitionAnimationDelegate.postponeTransition(); + mInitialProfile = findSelectedProfile(); Tracer.INSTANCE.markLaunched(); } @@ -706,7 +713,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void onChooserRequestChanged(ChooserRequest chooserRequest) { - // intentional reference comparison if (mRequest == chooserRequest) { return; } @@ -725,6 +731,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements setTabsViewEnabled(false); } + private void onHasSelections(boolean hasSelections) { + mChooserMultiProfilePagerAdapter.setTargetsEnabled(hasSelections); + } + private void onAppTargetsLoaded(ResolverListAdapter listAdapter) { Log.d(TAG, "onAppTargetsLoaded(" + "listAdapter.userHandle=" + listAdapter.getUserHandle() + ")"); @@ -755,10 +765,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Intent newTargetIntent = newChooserRequest.getTargetIntent(); List<Intent> oldAltIntents = oldChooserRequest.getAdditionalTargets(); List<Intent> newAltIntents = newChooserRequest.getAdditionalTargets(); + List<ComponentName> oldExcluded = oldChooserRequest.getFilteredComponentNames(); + List<ComponentName> newExcluded = newChooserRequest.getFilteredComponentNames(); // TODO: a workaround for the unnecessary target reloading caused by multiple flow updates - // an artifact of the current implementation; revisit. - return !oldTargetIntent.equals(newTargetIntent) || !oldAltIntents.equals(newAltIntents); + return !oldTargetIntent.equals(newTargetIntent) + || !oldAltIntents.equals(newAltIntents) + || (shareouselUpdateExcludeComponentsExtra() + && !oldExcluded.equals(newExcluded)); } private void recreatePagerAdapter() { @@ -782,11 +797,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } // Update the pager adapter but do not attach it to the view till the targets are reloaded, // see onChooserAppTargetsLoaded method. + ChooserMultiProfilePagerAdapter oldPagerAdapter = + mChooserMultiProfilePagerAdapter; mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( /* context = */ this, mProfilePagerResources, mRequest, mProfiles, + mProfileRecords.values(), mProfileAvailability, mRequest.getInitialIntents(), mMaxTargetsPerRow); @@ -820,6 +838,19 @@ public class ChooserActivity extends Hilt_ChooserActivity implements postRebuildList( mChooserMultiProfilePagerAdapter.rebuildTabs( mProfiles.getWorkProfilePresent() || mProfiles.getPrivateProfilePresent())); + if (fixShortcutsFlashing() && oldPagerAdapter != null) { + for (int i = 0, count = mChooserMultiProfilePagerAdapter.getCount(); i < count; i++) { + ChooserListAdapter listAdapter = + mChooserMultiProfilePagerAdapter.getPageAdapterForIndex(i) + .getListAdapter(); + ChooserListAdapter oldListAdapter = + oldPagerAdapter.getListAdapterForUserHandle(listAdapter.getUserHandle()); + if (oldListAdapter != null) { + listAdapter.copyDirectTargetsFrom(oldListAdapter); + listAdapter.setDirectTargetsEnabled(false); + } + } + } setTabsViewEnabled(false); } @@ -837,7 +868,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { if (mViewPager != null) { - mViewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + int profile = savedInstanceState.getInt(LAST_SHOWN_PROFILE); + int profileNumber = mChooserMultiProfilePagerAdapter.getPageNumberForProfile(profile); + if (profileNumber != -1) { + mViewPager.setCurrentItem(profileNumber); + mInitialProfile = profile; + } } mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); } @@ -1088,7 +1124,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (cti.startAsCaller(this, options, user.getIdentifier())) { // Prevent sending a second chooser result when starting the edit action intent. if (!cti.getTargetIntent().hasExtra(EDIT_SOURCE)) { - maybeSendShareResult(cti); + maybeSendShareResult(cti, user); } maybeLogCrossProfileTargetLaunch(cti, user); } @@ -1250,6 +1286,18 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mTabHost = findViewById(com.android.internal.R.id.profile_tabhost); mViewPager = requireViewById(com.android.internal.R.id.profile_pager); mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); + ChooserNestedScrollView scrollableContainer = + requireViewById(R.id.chooser_scrollable_container); + if (keyboardNavigationFix()) { + scrollableContainer.setRequestChildFocusPredicate((child, focused) -> + // TabHost view will request focus on the newly activated tab. The RecyclerView + // from the tab gets focused and notifies its parents (including + // NestedScrollView) about it through #requestChildFocus method call. + // NestedScrollView's view implementation of the method will scroll to the + // focused view. As we don't want to change drawer's position upon tab change, + // ignore focus requests from tab RecyclerViews. + focused == null || focused.getId() != com.android.internal.R.id.resolver_list); + } boolean result = postRebuildList(rebuildCompleted); Trace.endSection(); return result; @@ -1346,26 +1394,32 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = mProfiles.getPersonalHandle(); - ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); - if (record.shortcutLoader == null) { - Tracer.INSTANCE.endLaunchToShortcutTrace(); - } - UserHandle workUserHandle = mProfiles.getWorkHandle(); - if (workUserHandle != null) { - createProfileRecord(workUserHandle, targetIntentFilter, factory); - } - - UserHandle privateUserHandle = mProfiles.getPrivateHandle(); - if (privateUserHandle != null && mProfileAvailability.isAvailable( - requireNonNull(mProfiles.getPrivateProfile()))) { - createProfileRecord(privateUserHandle, targetIntentFilter, factory); + Profile launchedAsProfile = mProfiles.getLaunchedAsProfile(); + for (Profile profile : mProfiles.getProfiles()) { + if (profile.getType() == Profile.Type.PRIVATE + && !mProfileAvailability.isAvailable(profile)) { + continue; + } + ProfileRecord record = createProfileRecord( + profile, + targetIntentFilter, + launchedAsProfile.equals(profile) + ? mRequest.getCallerChooserTargets() + : Collections.emptyList(), + factory); + if (profile.equals(launchedAsProfile) && record.shortcutLoader == null) { + Tracer.INSTANCE.endLaunchToShortcutTrace(); + } } } private ProfileRecord createProfileRecord( - UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { + Profile profile, + IntentFilter targetIntentFilter, + List<ChooserTarget> callerTargets, + AppPredictorFactory factory) { + UserHandle userHandle = profile.getPrimary().getHandle(); AppPredictor appPredictor = factory.create(userHandle); ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() ? null @@ -1375,7 +1429,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements userHandle, targetIntentFilter, shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); - ProfileRecord record = new ProfileRecord(appPredictor, shortcutLoader); + ProfileRecord record = new ProfileRecord( + profile, appPredictor, shortcutLoader, callerTargets); mProfileRecords.put(userHandle.getIdentifier(), record); return record; } @@ -1410,6 +1465,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ProfilePagerResources profilePagerResources, ChooserRequest request, ProfileHelper profileHelper, + Collection<ProfileRecord> profileRecords, ProfileAvailability profileAvailability, List<Intent> initialIntents, int maxTargetsPerRow) { @@ -1421,11 +1477,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements List<Intent> payloadIntents = request.getPayloadIntents(); List<TabConfig<ChooserGridAdapter>> tabs = new ArrayList<>(); - for (Profile profile : profileHelper.getProfiles()) { - if (profile.getType() == Profile.Type.PRIVATE - && !profileAvailability.isAvailable(profile)) { - continue; - } + for (ProfileRecord record : profileRecords) { + Profile profile = record.profile; ChooserGridAdapter adapter = createChooserGridAdapter( context, payloadIntents, @@ -1529,6 +1582,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); mChooserMultiProfilePagerAdapter.setMaxTargetsPerRow(mMaxTargetsPerRow); + adjustMaxPreviewWidth(); adjustPreviewWidth(newConfig.orientation, null); updateStickyContentPreview(); updateTabPadding(); @@ -1541,6 +1595,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return orientation == Configuration.ORIENTATION_LANDSCAPE && !isInMultiWindowMode(); } + private void adjustMaxPreviewWidth() { + if (mResolverDrawerLayout == null) { + return; + } + mResolverDrawerLayout.setMaxWidth( + getResources().getDimensionPixelSize(R.dimen.chooser_width)); + } + private void adjustPreviewWidth(int orientation, View parent) { int width = -1; if (mShouldDisplayLandscape) { @@ -1640,26 +1702,29 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return result; } - private void maybeSendShareResult(TargetInfo cti) { + private void maybeSendShareResult(TargetInfo cti, UserHandle launchedAsUser) { if (mShareResultSender != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { - mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo()); + boolean crossProfile = !UserHandle.of(UserHandle.myUserId()).equals(launchedAsUser); + mShareResultSender.onComponentSelected( + target, cti.isChooserTargetInfo(), crossProfile); } } } - private void addCallerChooserTargets() { - if (!mRequest.getCallerChooserTargets().isEmpty()) { - // Send the caller's chooser targets only to the default profile. - if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( - /* origTarget */ null, - new ArrayList<>(mRequest.getCallerChooserTargets()), - TARGET_TYPE_DEFAULT, - /* directShareShortcutInfoCache */ Collections.emptyMap(), - /* directShareAppTargetCache */ Collections.emptyMap()); - } + private void addCallerChooserTargets(ChooserListAdapter adapter) { + ProfileRecord record = getProfileRecord(adapter.getUserHandle()); + List<ChooserTarget> callerTargets = record == null + ? Collections.emptyList() + : record.callerTargets; + if (!callerTargets.isEmpty()) { + adapter.addServiceResults( + /* origTarget */ null, + new ArrayList<>(mRequest.getCallerChooserTargets()), + TARGET_TYPE_DEFAULT, + /* directShareShortcutInfoCache */ Collections.emptyMap(), + /* directShareAppTargetCache */ Collections.emptyMap()); } } @@ -2037,7 +2102,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements initialIntents, rList, filterLastUsed, - createListController(userHandle), + resolverListController, userHandle, targetIntent, referrerFillInIntent, @@ -2052,8 +2117,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - }, - mFeatureFlags); + }); } private void onWorkProfileStatusUpdated() { @@ -2108,11 +2172,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mPinnedSharedPrefs); } - @VisibleForTesting - protected ViewModelProvider.Factory createPreviewViewModelFactory() { - return PreviewViewModel.Companion.getFactory(); - } - private ChooserContentPreviewUi.ActionFactory decorateActionFactoryWithRefinement( ChooserContentPreviewUi.ActionFactory originalFactory) { if (!mFeatureFlags.refineSystemActions()) { @@ -2123,6 +2182,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override @Nullable public Runnable getEditButtonRunnable() { + if (originalFactory.getEditButtonRunnable() == null) return null; return () -> { if (!mRefinementManager.maybeHandleSelection( RefinementType.EDIT_ACTION, @@ -2139,6 +2199,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override @Nullable public Runnable getCopyButtonRunnable() { + if (originalFactory.getCopyButtonRunnable() == null) return null; return () -> { if (!mRefinementManager.maybeHandleSelection( RefinementType.COPY_ACTION, @@ -2208,8 +2269,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements }, mShareResultSender, this::finishWithStatus, - mClipboardManager, - mFeatureFlags); + mClipboardManager); } private Supplier<ActionRow.Action> createModifyShareActionFactory() { @@ -2249,8 +2309,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight(); + final int maxChooserWidth = getResources().getDimensionPixelSize(R.dimen.chooser_width); boolean isLayoutUpdated = - gridAdapter.calculateChooserTargetWidth(availableWidth) + gridAdapter.calculateChooserTargetWidth( + maxChooserWidth >= 0 + ? Math.min(maxChooserWidth, availableWidth) + : availableWidth) || recyclerView.getAdapter() == null || availableWidth != mCurrAvailableWidth; @@ -2258,7 +2322,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (isLayoutUpdated || insetsChanged - || mLastNumberOfChildren != recyclerView.getChildCount()) { + || mLastNumberOfChildren != recyclerView.getChildCount() + || mFeatureFlags.fixMissingDrawerOffsetCalculation()) { mCurrAvailableWidth = availableWidth; if (isLayoutUpdated) { // It is very important we call setAdapter from here. Otherwise in some cases @@ -2272,12 +2337,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile(); - int initialProfile = findSelectedProfile(); + int initialProfile = Flags.fixDrawerOffsetOnConfigChange() + ? mInitialProfile + : findSelectedProfile(); if (currentProfile != initialProfile) { return; } - if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) { + if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged + && !mFeatureFlags.fixMissingDrawerOffsetCalculation()) { return; } @@ -2404,7 +2472,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (duration >= 0) { Log.d(TAG, "app target loading time " + duration + " ms"); } - addCallerChooserTargets(); + if (!fixShortcutsFlashing()) { + addCallerChooserTargets(chooserListAdapter); + } getEventLog().logSharesheetAppLoadComplete(); maybeQueryAdditionalPostProcessingTargets( listProfileUserHandle, @@ -2434,6 +2504,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); if (adapter != null) { + if (fixShortcutsFlashing()) { + adapter.setDirectTargetsEnabled(true); + addCallerChooserTargets(adapter); + } for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) { adapter.addServiceResults( resultInfo.getAppTarget(), @@ -2675,6 +2749,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private static class ProfileRecord { + public final Profile profile; + /** The {@link AppPredictor} for this profile, if any. */ @Nullable public final AppPredictor appPredictor; @@ -2683,19 +2759,27 @@ public class ChooserActivity extends Hilt_ChooserActivity implements */ @Nullable public final ShortcutLoader shortcutLoader; + public final List<ChooserTarget> callerTargets; public long loadingStartTime; private ProfileRecord( + Profile profile, @Nullable AppPredictor appPredictor, - @Nullable ShortcutLoader shortcutLoader) { + @Nullable ShortcutLoader shortcutLoader, + List<ChooserTarget> callerTargets) { + this.profile = profile; this.appPredictor = appPredictor; this.shortcutLoader = shortcutLoader; + this.callerTargets = callerTargets; } public void destroy() { if (appPredictor != null) { appPredictor.destroy(); } + if (shortcutLoader != null) { + shortcutLoader.destroy(); + } } } } diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt index 312911a6..c26dd77c 100644 --- a/java/src/com/android/intentresolver/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/ChooserHelper.kt @@ -27,7 +27,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository import com.android.intentresolver.data.model.ChooserRequest @@ -39,6 +41,8 @@ import com.android.intentresolver.validation.log import dagger.hilt.android.scopes.ActivityScoped import java.util.function.Consumer import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -46,6 +50,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch private const val TAG: String = "ChooserHelper" @@ -98,6 +103,7 @@ constructor( var onChooserRequestChanged: Consumer<ChooserRequest> = Consumer {} /** Invoked when there are a new change to payload selection */ var onPendingSelection: Runnable = Runnable {} + var onHasSelections: Consumer<Boolean> = Consumer {} init { activity.lifecycle.addObserver(this) @@ -144,22 +150,39 @@ constructor( } activity.lifecycleScope.launch { - val hasPendingCallbackFlow = + val hasPendingIntentFlow = pendingSelectionCallbackRepo.pendingTargetIntent .map { it != null } .distinctUntilChanged() - .onEach { hasPendingCallback -> - if (hasPendingCallback) { + .onEach { hasPendingIntent -> + if (hasPendingIntent) { onPendingSelection.run() } } activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.request - .combine(hasPendingCallbackFlow) { request, hasPendingCallback -> - request to hasPendingCallback + val hasSelectionFlow = + if ( + unselectFinalItem() && + viewModel.previewDataProvider.previewType == + CONTENT_PREVIEW_PAYLOAD_SELECTION + ) { + viewModel.shareouselViewModel.hasSelectedItems.stateIn(scope = this).also { + flow -> + launch { flow.collect { onHasSelections.accept(it) } } + } + } else { + MutableStateFlow(true).asStateFlow() } + val requestControlFlow = + hasSelectionFlow + .combine(hasPendingIntentFlow) { hasSelections, hasPendingIntent -> + hasSelections && !hasPendingIntent + } + .distinctUntilChanged() + viewModel.request + .combine(requestControlFlow) { request, isReady -> request to isReady } // only take ChooserRequest if there are no pending callbacks - .filter { !it.second } + .filter { it.second } .map { it.first } .distinctUntilChanged(areEquivalent = { old, new -> old === new }) .collect { onChooserRequestChanged.accept(it) } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index ff0c40d7..016eb714 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -111,7 +111,6 @@ 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<>(); @@ -154,6 +153,8 @@ public class ChooserListAdapter extends ResolverListAdapter { }; private boolean mAnimateItems = true; + private boolean mTargetsEnabled = true; + private boolean mDirectTargetsEnabled = true; public ChooserListAdapter( Context context, @@ -171,8 +172,7 @@ public class ChooserListAdapter extends ResolverListAdapter { int maxRankedTargets, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader, - @Nullable PackageChangeCallback packageChangeCallback, - FeatureFlags featureFlags) { + @Nullable PackageChangeCallback packageChangeCallback) { this( context, payloadIntents, @@ -191,8 +191,8 @@ public class ChooserListAdapter extends ResolverListAdapter { targetDataLoader, packageChangeCallback, AsyncTask.SERIAL_EXECUTOR, - context.getMainExecutor(), - featureFlags); + context.getMainExecutor() + ); } @VisibleForTesting @@ -214,8 +214,7 @@ public class ChooserListAdapter extends ResolverListAdapter { TargetDataLoader targetDataLoader, @Nullable PackageChangeCallback packageChangeCallback, Executor bgExecutor, - Executor mainExecutor, - FeatureFlags featureFlags) { + Executor mainExecutor) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super( @@ -239,7 +238,6 @@ public class ChooserListAdapter extends ResolverListAdapter { mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); mTargetDataLoader = targetDataLoader; mPackageChangeCallback = packageChangeCallback; - mUseBadgeTextViewForLabels = featureFlags.bespokeLabelView(); createPlaceHolders(); mEventLog = eventLog; mShortcutSelectionLogic = new ShortcutSelectionLogic( @@ -310,6 +308,28 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + /** + * Set the enabled state for all targets. + */ + public void setTargetsEnabled(boolean isEnabled) { + if (mTargetsEnabled != isEnabled) { + mTargetsEnabled = isEnabled; + notifyDataSetChanged(); + } + } + + /** + * Set the enabled state for direct targets. + */ + public void setDirectTargetsEnabled(boolean isEnabled) { + if (mDirectTargetsEnabled != isEnabled) { + mDirectTargetsEnabled = isEnabled; + if (!mServiceTargets.isEmpty() && !isDirectTargetRowEmptyState()) { + notifyDataSetChanged(); + } + } + } + public void setAnimateItems(boolean animateItems) { mAnimateItems = animateItems; } @@ -345,12 +365,7 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override View onCreateView(ViewGroup parent) { - return mInflater.inflate( - mUseBadgeTextViewForLabels - ? R.layout.chooser_grid_item - : R.layout.resolve_grid_item, - parent, - false); + return mInflater.inflate(R.layout.chooser_grid_item, parent, false); } @Override @@ -362,7 +377,8 @@ public class ChooserListAdapter extends ResolverListAdapter { @VisibleForTesting @Override public void onBindView(View view, TargetInfo info, int position) { - view.setEnabled(!isDestroyed()); + final boolean isEnabled = !isDestroyed() && mTargetsEnabled; + view.setEnabled(isEnabled); final ViewHolder holder = (ViewHolder) view.getTag(); resetViewHolder(holder); @@ -387,6 +403,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } if (info.isSelectableTargetInfo()) { + view.setEnabled(isEnabled && mDirectTargetsEnabled); // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); CharSequence appName = @@ -421,7 +438,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - holder.bindIcon(info); + holder.bindIcon(info, mTargetsEnabled); if (mAnimateItems && info.hasDisplayIcon()) { mAnimationTracker.animateIcon(holder.icon, info); } @@ -448,9 +465,7 @@ public class ChooserListAdapter extends ResolverListAdapter { holder.reset(); holder.itemView.setBackground(holder.defaultItemViewBackground); - if (mUseBadgeTextViewForLabels) { - ((BadgeTextView) holder.text).setBadgeDrawable(null); - } + ((BadgeTextView) holder.text).setBadgeDrawable(null); holder.text.setBackground(null); holder.text.setPaddingRelative(0, 0, 0, 0); } @@ -464,12 +479,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } 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); - } + ((BadgeTextView) holder.text).setBadgeDrawable(indicator); } private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) { @@ -748,7 +758,7 @@ public class ChooserListAdapter extends ResolverListAdapter { Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos, Map<ChooserTarget, AppTarget> directShareToAppTargets) { // Avoid inserting any potentially late results. - if ((mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo()) { + if (isDirectTargetRowEmptyState()) { return; } boolean isShortcutResult = targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER @@ -771,6 +781,22 @@ public class ChooserListAdapter extends ResolverListAdapter { } /** + * Copy direct targets from another ChooserListAdapter instance + */ + public void copyDirectTargetsFrom(ChooserListAdapter adapter) { + if (adapter.isDirectTargetRowEmptyState()) { + return; + } + + mServiceTargets.clear(); + mServiceTargets.addAll(adapter.mServiceTargets); + } + + private boolean isDirectTargetRowEmptyState() { + return (mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo(); + } + + /** * Use the scoring system along with artificial boosts to create up to 4 distinct buckets: * <ol> * <li>App-supplied targets diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java deleted file mode 100644 index 06f56e3b..00000000 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ /dev/null @@ -1,504 +0,0 @@ -/* - * Copyright (C) 2008 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - - -import android.content.ComponentName; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.IntentSender; -import android.net.Uri; -import android.os.Bundle; -import android.os.Parcelable; -import android.os.PatternMatcher; -import android.service.chooser.ChooserAction; -import android.service.chooser.ChooserTarget; -import android.text.TextUtils; -import android.util.Log; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.intentresolver.util.UriFilters; - -import com.google.common.collect.ImmutableList; - -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collector; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * Utility to parse and validate parameters from the client-supplied {@link Intent} that launched - * the Sharesheet {@link ChooserActivity}. The validated parameters are stored as immutable ivars. - * - * TODO: field nullability in this class reflects legacy use, and typically would indicate that the - * client's intent didn't provide the respective data. In some cases we may be able to provide - * defaults instead of nulls -- especially for methods that return nullable lists or arrays, if the - * client code could instead handle empty collections equally well. - * - * TODO: some of these fields (especially getTargetIntent() and any other getters that delegate to - * it internally) differ from the legacy model because they're computed directly from the initial - * Chooser intent, where in the past they've been relayed up to ResolverActivity and then retrieved - * through methods on the base class. The base always seems to return them exactly as they were - * provided, so this should be safe -- and clients can reasonably switch to retrieving through these - * parameters instead. For now, the other convention is still used in some places. Ideally we'd like - * to normalize on a single source of truth, but we'll have to clean up the delegation up to the - * resolver (or perhaps this needs to be a subclass of some `ResolverRequestParameters` class?). - */ -public class ChooserRequestParameters { - private static final String TAG = "ChooserActivity"; - - private static final int LAUNCH_FLAGS_FOR_SEND_ACTION = - Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; - private static final int MAX_CHOOSER_ACTIONS = 5; - - private final Intent mTarget; - private final String mReferrerPackageName; - private final Pair<CharSequence, Integer> mTitleSpec; - private final Intent mReferrerFillInIntent; - private final ImmutableList<ComponentName> mFilteredComponentNames; - private final ImmutableList<ChooserTarget> mCallerChooserTargets; - private final @NonNull ImmutableList<ChooserAction> mChooserActions; - private final ChooserAction mModifyShareAction; - private final boolean mRetainInOnStop; - - @Nullable - private final ImmutableList<Intent> mAdditionalTargets; - - @Nullable - private final Bundle mReplacementExtras; - - @Nullable - private final ImmutableList<Intent> mInitialIntents; - - @Nullable - private final IntentSender mChosenComponentSender; - - @Nullable - private final IntentSender mRefinementIntentSender; - - @Nullable - private final String mSharedText; - - @Nullable - private final IntentFilter mTargetIntentFilter; - - @Nullable - private final CharSequence mMetadataText; - - public ChooserRequestParameters( - final Intent clientIntent, - String referrerPackageName, - final Uri referrer) { - final Intent requestedTarget = parseTargetIntentExtra( - clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); - mTarget = intentWithModifiedLaunchFlags(requestedTarget); - - mReferrerPackageName = referrerPackageName; - - mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( - clientIntent, Intent.EXTRA_ALTERNATE_INTENTS); - - mReplacementExtras = clientIntent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS); - - mTitleSpec = makeTitleSpec( - clientIntent.getCharSequenceExtra(Intent.EXTRA_TITLE), - isSendAction(mTarget.getAction())); - - mInitialIntents = intentsWithModifiedLaunchFlagsFromExtraIfPresent( - clientIntent, Intent.EXTRA_INITIAL_INTENTS); - - mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer); - - mChosenComponentSender = - 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); - - ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra( - Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class); - mFilteredComponentNames = filteredComponents != null - ? ImmutableList.copyOf(filteredComponents) - : ImmutableList.of(); - - mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); - - mRetainInOnStop = clientIntent.getBooleanExtra( - ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false); - - mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT); - - mTargetIntentFilter = getTargetIntentFilter(mTarget); - - 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() { - return mTarget; - } - - @Nullable - public String getTargetAction() { - return getTargetIntent().getAction(); - } - - public boolean isSendActionTarget() { - return isSendAction(getTargetAction()); - } - - @Nullable - public String getTargetType() { - return getTargetIntent().getType(); - } - - public String getReferrerPackageName() { - return mReferrerPackageName; - } - - @Nullable - public CharSequence getTitle() { - return mTitleSpec.first; - } - - public int getDefaultTitleResource() { - return mTitleSpec.second; - } - - public Intent getReferrerFillInIntent() { - return mReferrerFillInIntent; - } - - public ImmutableList<ComponentName> getFilteredComponentNames() { - return mFilteredComponentNames; - } - - public ImmutableList<ChooserTarget> getCallerChooserTargets() { - return mCallerChooserTargets; - } - - @NonNull - public ImmutableList<ChooserAction> getChooserActions() { - return mChooserActions; - } - - @Nullable - public ChooserAction getModifyShareAction() { - return mModifyShareAction; - } - - /** - * Whether the {@link ChooserActivity#EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. - */ - public boolean shouldRetainInOnStop() { - return mRetainInOnStop; - } - - /** - * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link #mAdditionalTargets} directly is simpler and safer. - */ - @Nullable - public Intent[] getAdditionalTargets() { - return (mAdditionalTargets == null) ? null : mAdditionalTargets.toArray(new Intent[0]); - } - - @Nullable - public Bundle getReplacementExtras() { - return mReplacementExtras; - } - - /** - * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link #mInitialIntents} directly is simpler and safer. - */ - @Nullable - public Intent[] getInitialIntents() { - return (mInitialIntents == null) ? null : mInitialIntents.toArray(new Intent[0]); - } - - @Nullable - public IntentSender getChosenComponentSender() { - return mChosenComponentSender; - } - - @Nullable - public IntentSender getRefinementIntentSender() { - return mRefinementIntentSender; - } - - @Nullable - public String getSharedText() { - return mSharedText; - } - - @Nullable - public IntentFilter getTargetIntentFilter() { - return mTargetIntentFilter; - } - - @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)); - } - - private static Intent parseTargetIntentExtra(@Nullable Parcelable targetParcelable) { - if (targetParcelable instanceof Uri) { - try { - targetParcelable = Intent.parseUri(targetParcelable.toString(), - Intent.URI_INTENT_SCHEME); - } catch (URISyntaxException ex) { - throw new IllegalArgumentException("Failed to parse EXTRA_INTENT from URI", ex); - } - } - - if (!(targetParcelable instanceof Intent)) { - throw new IllegalArgumentException( - "EXTRA_INTENT is neither an Intent nor a Uri: " + targetParcelable); - } - - return ((Intent) targetParcelable); - } - - private static Intent intentWithModifiedLaunchFlags(Intent intent) { - if (isSendAction(intent.getAction())) { - intent.addFlags(LAUNCH_FLAGS_FOR_SEND_ACTION); - } - return intent; - } - - /** - * Build a pair of values specifying the title to use from the client request. The first - * ({@link CharSequence}) value is the client-specified title, if there was one and their - * requested target <em>wasn't</em> a send action; otherwise it is null. The second value is - * the resource ID of a default title string; this is nonzero only if the first value is null. - * - * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate}, or - * create a real type (not {@link Pair}) to express the semantics described in this comment. - */ - private static Pair<CharSequence, Integer> makeTitleSpec( - @Nullable CharSequence requestedTitle, boolean hasSendActionTarget) { - if (hasSendActionTarget && (requestedTitle != null)) { - // Do not allow the title to be changed when sharing content - Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" - + " preview title by using EXTRA_TITLE property of the wrapped" - + " EXTRA_INTENT."); - requestedTitle = null; - } - - int defaultTitleRes = (requestedTitle == null) ? R.string.chooseActivity : 0; - - return Pair.create(requestedTitle, defaultTitleRes); - } - - private static ImmutableList<ChooserTarget> parseCallerTargetsFromClientIntent( - Intent clientIntent) { - return - streamParcelableArrayExtra( - clientIntent, Intent.EXTRA_CHOOSER_TARGETS, ChooserTarget.class, true, true) - .collect(toImmutableList()); - } - - @NonNull - private static ImmutableList<ChooserAction> getChooserActions(Intent intent) { - return streamParcelableArrayExtra( - intent, - Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, - ChooserAction.class, - true, - true) - .filter(UriFilters::hasValidIcon) - .limit(MAX_CHOOSER_ACTIONS) - .collect(toImmutableList()); - } - - @Nullable - private static ChooserAction getModifyShareAction(Intent intent) { - try { - return intent.getParcelableExtra( - Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, - ChooserAction.class); - } catch (Throwable t) { - Log.w( - TAG, - "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION argument", - t); - return null; - } - } - - private static <T> Collector<T, ?, ImmutableList<T>> toImmutableList() { - return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); - } - - @Nullable - private static ImmutableList<Intent> intentsWithModifiedLaunchFlagsFromExtraIfPresent( - Intent clientIntent, String extra) { - Stream<Intent> intents = - streamParcelableArrayExtra(clientIntent, extra, Intent.class, true, false); - if (intents == null) { - return null; - } - return intents - .map(ChooserRequestParameters::intentWithModifiedLaunchFlags) - .collect(toImmutableList()); - } - - /** - * Make a {@link Stream} of the {@link Parcelable} objects given in the provided {@link Intent} - * as the optional parcelable array extra with key {@code extra}. The stream elements, if any, - * are all of the type specified by {@code clazz}. - * - * @param intent The intent that may contain the optional extras. - * @param extra The extras key to identify the parcelable array. - * @param clazz A class that is assignable from any elements in the result stream. - * @param warnOnTypeError Whether to log a warning (and ignore) if the client extra doesn't have - * the required type. If false, throw an {@link IllegalArgumentException} if the extra is - * non-null but can't be assigned to variables of type {@code T}. - * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't - * present in the intent (or if it had the wrong type, but <em>warnOnTypeError</em> is true). - * If false, return null in these cases, and only return an empty stream if the intent - * explicitly provided an empty array for the specified extra. - */ - @Nullable - private static <T extends Parcelable> Stream<T> streamParcelableArrayExtra( - final Intent intent, - String extra, - @NonNull Class<T> clazz, - boolean warnOnTypeError, - boolean streamEmptyIfNull) { - T[] result = null; - - try { - result = getParcelableArrayExtraIfPresent(intent, extra, clazz); - } catch (IllegalArgumentException e) { - if (warnOnTypeError) { - Log.w(TAG, "Ignoring client-requested " + extra, e); - } else { - throw e; - } - } - - if (result != null) { - return Arrays.stream(result); - } else if (streamEmptyIfNull) { - return Stream.empty(); - } else { - return null; - } - } - - /** - * If the specified {@code extra} is provided in the {@code intent}, cast it to type {@code T[]} - * or throw an {@code IllegalArgumentException} if the cast fails. If the {@code extra} isn't - * present in the {@code intent}, return null. - */ - @Nullable - private static <T extends Parcelable> T[] getParcelableArrayExtraIfPresent( - final Intent intent, String extra, @NonNull Class<T> clazz) throws - IllegalArgumentException { - if (!intent.hasExtra(extra)) { - return null; - } - - T[] castResult = intent.getParcelableArrayExtra(extra, clazz); - if (castResult == null) { - Parcelable[] actualExtrasArray = intent.getParcelableArrayExtra(extra); - if (actualExtrasArray != null) { - throw new IllegalArgumentException( - String.format( - "%s is not of type %s[]: %s", - extra, - clazz.getSimpleName(), - Arrays.toString(actualExtrasArray))); - } else if (intent.getParcelableExtra(extra) != null) { - throw new IllegalArgumentException( - String.format( - "%s is not of type %s[] (or any array type): %s", - extra, - clazz.getSimpleName(), - intent.getParcelableExtra(extra))); - } else { - throw new IllegalArgumentException( - String.format( - "%s is not of type %s (or any Parcelable type): %s", - extra, - clazz.getSimpleName(), - intent.getExtras().get(extra))); - } - } - - return castResult; - } - - private static IntentFilter getTargetIntentFilter(final Intent intent) { - try { - String dataString = intent.getDataString(); - if (intent.getType() == null) { - if (!TextUtils.isEmpty(dataString)) { - return new IntentFilter(intent.getAction(), dataString); - } - Log.e(TAG, "Failed to get target intent filter: intent data and type are null"); - return null; - } - IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType()); - List<Uri> contentUris = new ArrayList<>(); - if (Intent.ACTION_SEND.equals(intent.getAction())) { - Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - contentUris.add(uri); - } - } else { - List<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris != null) { - contentUris.addAll(uris); - } - } - for (Uri uri : contentUris) { - intentFilter.addDataScheme(uri.getScheme()); - intentFilter.addDataAuthority(uri.getAuthority(), null); - intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL); - } - return intentFilter; - } catch (Exception e) { - Log.e(TAG, "Failed to get target intent filter", e); - return null; - } - } -} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index a402fc72..2f220cf1 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -21,7 +21,7 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static androidx.lifecycle.LifecycleKt.getCoroutineScope; -import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.ext.CreationExtrasExtKt.replaceDefaultArgs; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import static java.util.Objects.requireNonNull; @@ -85,6 +85,7 @@ import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.data.repository.ActivityModelRepository; import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; @@ -103,10 +104,10 @@ import com.android.intentresolver.profiles.OnProfileSelectedListener; import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.profiles.ResolverMultiProfilePagerAdapter; import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.ActivityModel; import com.android.intentresolver.shared.model.Profile; import com.android.intentresolver.ui.ActionTitle; import com.android.intentresolver.ui.ProfilePagerResources; -import com.android.intentresolver.ui.model.ActivityModel; import com.android.intentresolver.ui.model.ResolverRequest; import com.android.intentresolver.ui.viewmodel.ResolverViewModel; import com.android.intentresolver.widget.ResolverDrawerLayout; @@ -119,8 +120,6 @@ import com.google.common.collect.ImmutableList; import dagger.hilt.android.AndroidEntryPoint; -import kotlin.Pair; - import kotlinx.coroutines.CoroutineDispatcher; import java.util.ArrayList; @@ -150,6 +149,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @Inject public ProfilePagerResources mProfilePagerResources; @Inject public IntentForwarding mIntentForwarding; @Inject public FeatureFlags mFeatureFlags; + @Inject public ActivityModelRepository mActivityModelRepository; private ResolverViewModel mViewModel; private ResolverRequest mRequest; @@ -220,15 +220,14 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @NonNull @Override public CreationExtras getDefaultViewModelCreationExtras() { - return addDefaultArgs( - super.getDefaultViewModelCreationExtras(), - new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, createActivityModel())); + return replaceDefaultArgs(super.getDefaultViewModelCreationExtras()); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.i(TAG, "onCreate"); + mActivityModelRepository.initialize(this::createActivityModel); setTheme(R.style.Theme_DeviceDefault_Resolver); mResolverHelper.setInitializer(this::initialize); } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 5fd37d43..fc5514b6 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -16,14 +16,15 @@ package com.android.intentresolver; +import static com.android.intentresolver.Flags.unselectFinalItem; +import static com.android.intentresolver.util.graphics.SuspendedMatrixColorFilter.getSuspendedColorMatrix; + import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.LabeledIntent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; -import android.graphics.ColorMatrix; -import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.Drawable; import android.os.AsyncTask; import android.os.RemoteException; @@ -63,9 +64,6 @@ import java.util.concurrent.atomic.AtomicBoolean; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; - @Nullable // TODO: other model for lazy computation? Or just precompute? - private static ColorMatrixColorFilter sSuspendedMatrixColorFilter; - protected final Context mContext; protected final LayoutInflater mInflater; protected final ResolverListCommunicator mResolverListCommunicator; @@ -797,29 +795,6 @@ public class ResolverListAdapter extends BaseAdapter { return mDestroyed.get(); } - private static ColorMatrixColorFilter getSuspendedColorMatrix() { - if (sSuspendedMatrixColorFilter == null) { - - int grayValue = 127; - float scale = 0.5f; // half bright - - ColorMatrix tempBrightnessMatrix = new ColorMatrix(); - float[] mat = tempBrightnessMatrix.getArray(); - mat[0] = scale; - mat[6] = scale; - mat[12] = scale; - mat[4] = grayValue; - mat[9] = grayValue; - mat[14] = grayValue; - - ColorMatrix matrix = new ColorMatrix(); - matrix.setSaturation(0.0f); - matrix.preConcat(tempBrightnessMatrix); - sSuspendedMatrixColorFilter = new ColorMatrixColorFilter(matrix); - } - return sSuspendedMatrixColorFilter; - } - protected final Drawable loadIconPlaceholder() { return mContext.getDrawable(R.drawable.resolver_icon_placeholder); } @@ -999,13 +974,26 @@ public class ResolverListAdapter extends BaseAdapter { /** * Bind view holder to a TargetInfo. */ - public void bindIcon(TargetInfo info) { + public final void bindIcon(TargetInfo info) { + bindIcon(info, true); + } + + /** + * Bind view holder to a TargetInfo. + */ + public void bindIcon(TargetInfo info, boolean isEnabled) { Drawable displayIcon = info.getDisplayIconHolder().getDisplayIcon(); icon.setImageDrawable(displayIcon); - if (info.isSuspended()) { + if (info.isSuspended() || !isEnabled) { icon.setColorFilter(getSuspendedColorMatrix()); } else { icon.setColorFilter(null); + if (unselectFinalItem() && displayIcon != null) { + // For some reason, ImageView.setColorFilter() not always propagate the call + // to the drawable and the icon remains grayscale when rebound; reset the filter + // explicitly. + displayIcon.setColorFilter(null); + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt deleted file mode 100644 index dc36e584..00000000 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ /dev/null @@ -1,35 +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.contentpreview - -import android.content.Intent -import android.net.Uri -import androidx.annotation.MainThread -import androidx.lifecycle.ViewModel - -/** A contract for the preview view model. Added for testing. */ -abstract class BasePreviewViewModel : ViewModel() { - @get:MainThread abstract val previewDataProvider: PreviewDataProvider - @get:MainThread abstract val imageLoader: ImageLoader - - @MainThread - abstract fun init( - targetIntent: Intent, - additionalContentUri: Uri?, - isPayloadTogglingEnabled: Boolean, - ) -} diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt index 2e2aa938..847fcc82 100644 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -19,10 +19,10 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri import android.util.Log +import android.util.Size import androidx.core.util.lruCache import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ViewModelOwned -import java.util.function.Consumer import javax.inject.Inject import javax.inject.Qualifier import kotlinx.coroutines.CoroutineDispatcher @@ -31,7 +31,6 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -74,15 +73,11 @@ constructor( } ) - override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { - callerScope.launch { callback.accept(loadCachedImage(uri)) } + override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) { + uriSizePairs.take(cache.maxSize()).map { cache[it.first] } } - override fun prePopulate(uris: List<Uri>) { - uris.take(cache.maxSize()).map { cache[it] } - } - - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? { + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? { return if (caching) { loadCachedImage(uri) } else { @@ -92,7 +87,7 @@ constructor( private suspend fun loadUncachedImage(uri: Uri): Bitmap? = withContext(bgDispatcher) { - runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } } + runCatching { semaphore.withPermit { thumbnailLoader.loadThumbnail(uri) } } .onFailure { ensureActive() Log.d(TAG, "Failed to load preview for $uri", it) diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 4b955c49..1128ec5d 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -22,7 +22,6 @@ import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTE import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; import android.content.ClipData; -import android.content.Intent; import android.content.res.Resources; import android.net.Uri; import android.text.TextUtils; @@ -34,6 +33,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.android.intentresolver.ContentTypeHint; +import com.android.intentresolver.data.model.ChooserRequest; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; @@ -102,7 +102,7 @@ public final class ChooserContentPreviewUi { public ChooserContentPreviewUi( CoroutineScope scope, PreviewDataProvider previewData, - Intent targetIntent, + ChooserRequest chooserRequest, ImageLoader imageLoader, ActionFactory actionFactory, Supplier</*@Nullable*/ActionRow.Action> modifyShareActionFactory, @@ -117,7 +117,7 @@ public final class ChooserContentPreviewUi { mModifyShareActionFactory = modifyShareActionFactory; mContentPreviewUi = createContentPreview( previewData, - targetIntent, + chooserRequest, DefaultMimeTypeClassifier.INSTANCE, imageLoader, actionFactory, @@ -133,7 +133,7 @@ public final class ChooserContentPreviewUi { private ContentPreviewUi createContentPreview( PreviewDataProvider previewData, - Intent targetIntent, + ChooserRequest chooserRequest, MimeTypeClassifier typeClassifier, ImageLoader imageLoader, ActionFactory actionFactory, @@ -146,7 +146,9 @@ public final class ChooserContentPreviewUi { if (previewType == CONTENT_PREVIEW_TEXT) { return createTextPreview( mScope, - targetIntent, + chooserRequest.getTargetIntent().getClipData(), + chooserRequest.getSharedText(), + chooserRequest.getSharedTextTitle(), actionFactory, imageLoader, headlineGenerator, @@ -174,15 +176,14 @@ public final class ChooserContentPreviewUi { boolean isSingleImageShare = previewData.getUriCount() == 1 && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); - CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - if (!TextUtils.isEmpty(text)) { + if (!TextUtils.isEmpty(chooserRequest.getSharedText())) { FilesPlusTextContentPreviewUi previewUi = new FilesPlusTextContentPreviewUi( mScope, isSingleImageShare, previewData.getUriCount(), - targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), - targetIntent.getType(), + chooserRequest.getSharedText(), + chooserRequest.getTargetType(), actionFactory, imageLoader, typeClassifier, @@ -201,7 +202,7 @@ public final class ChooserContentPreviewUi { return new UnifiedContentPreviewUi( mScope, isSingleImageShare, - targetIntent.getType(), + chooserRequest.getTargetType(), actionFactory, imageLoader, typeClassifier, @@ -243,16 +244,15 @@ public final class ChooserContentPreviewUi { private static TextContentPreviewUi createTextPreview( CoroutineScope scope, - Intent targetIntent, + ClipData previewData, + @Nullable CharSequence sharingText, + @Nullable CharSequence previewTitle, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, 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(); Uri previewThumbnail = null; if (previewData != null) { if (previewData.getItemCount() > 0) { diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index b50f5bc8..30161cfb 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -23,6 +23,7 @@ import android.content.res.Resources; import android.net.Uri; import android.text.util.Linkify; import android.util.PluralsMessageFormatter; +import android.util.Size; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -68,6 +69,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private Uri mFirstFilePreviewUri; private boolean mAllImages; private boolean mAllVideos; + private int mPreviewSize; // TODO(b/285309527): make this a flag private static final boolean SHOW_TOGGLE_CHECKMARK = false; @@ -109,6 +111,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) { + mPreviewSize = resources.getDimensionPixelSize(R.dimen.width_text_image_preview_size); return displayInternal(layoutInflater, parent, headlineViewParent); } @@ -164,12 +167,12 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private void updateUiWithMetadata(ViewGroup contentPreviewView, View headlineView) { prepareTextPreview(contentPreviewView, headlineView, mActionFactory); updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos); - ImageView imagePreview = mContentPreviewView.requireViewById(R.id.image_view); if (mIsSingleImage && mFirstFilePreviewUri != null) { mImageLoader.loadImage( mScope, mFirstFilePreviewUri, + new Size(mPreviewSize, mPreviewSize), bitmap -> { if (bitmap == null) { imagePreview.setVisibility(View.GONE); diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt index 21308341..059ee083 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt @@ -36,4 +36,6 @@ interface HeadlineGenerator { fun getVideosHeadline(count: Int): String fun getFilesHeadline(count: Int): String + + fun getNotItemsSelectedHeadline(): String } diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index e92d9bc6..822d3097 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -93,6 +93,9 @@ constructor( return getPluralString(R.string.sharing_files, count) } + override fun getNotItemsSelectedHeadline(): String = + context.getString(R.string.select_items_to_share) + private fun getPluralString(@StringRes templateResource: Int, count: Int): String { return PluralsMessageFormatter.format( context.resources, diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index 81913a8e..ac34f552 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -18,28 +18,39 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri +import android.util.Size import java.util.function.Consumer import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch /** A content preview image loader. */ -interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? { +interface ImageLoader : suspend (Uri, Size) -> Bitmap?, suspend (Uri, Size, Boolean) -> Bitmap? { /** * Load preview image asynchronously; caching is allowed. * * @param uri content URI + * @param size target bitmap size * @param callback a callback that will be invoked with the loaded image or null if loading has * failed. */ - fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) + fun loadImage(callerScope: CoroutineScope, uri: Uri, size: Size, callback: Consumer<Bitmap?>) { + callerScope.launch { + val bitmap = invoke(uri, size) + if (isActive) { + callback.accept(bitmap) + } + } + } /** Prepopulate the image loader cache. */ - fun prePopulate(uris: List<Uri>) + fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) /** Returns a bitmap for the given URI if it's already cached, otherwise null */ fun getCachedBitmap(uri: Uri): Bitmap? = null /** Load preview image; caching is allowed. */ - override suspend fun invoke(uri: Uri) = invoke(uri, true) + override suspend fun invoke(uri: Uri, size: Size) = invoke(uri, size, true) /** * Load preview image. @@ -47,5 +58,5 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm * @param uri content URI * @param caching indicates if the loaded image could be cached. */ - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? } diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt index 7035f765..27e817db 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -17,28 +17,34 @@ package com.android.intentresolver.contentpreview import android.content.res.Resources +import com.android.intentresolver.Flags import com.android.intentresolver.R import com.android.intentresolver.inject.ApplicationOwned import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityRetainedComponent -import dagger.hilt.android.scopes.ActivityRetainedScoped +import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Provider @Module -@InstallIn(ActivityRetainedComponent::class) +@InstallIn(ViewModelComponent::class) interface ImageLoaderModule { - @Binds - @ActivityRetainedScoped - fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader - - @Binds - @ActivityRetainedScoped - fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader + @Binds fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader companion object { @Provides + fun imageLoader( + imagePreviewImageLoader: Provider<ImagePreviewImageLoader>, + previewImageLoader: Provider<PreviewImageLoader> + ): ImageLoader = + if (Flags.previewImageLoader()) { + previewImageLoader.get() + } else { + imagePreviewImageLoader.get() + } + + @Provides @ThumbnailSize fun thumbnailSize(@ApplicationOwned resources: Resources): Int = resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt index fab7203e..379bdb37 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -25,7 +25,6 @@ import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.collection.LruCache import com.android.intentresolver.inject.Background -import java.util.function.Consumer import javax.inject.Inject import javax.inject.Qualifier import kotlinx.coroutines.CancellationException @@ -36,7 +35,6 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore @@ -100,19 +98,11 @@ constructor( @GuardedBy("lock") private val cache = LruCache<Uri, RequestRecord>(cacheSize) @GuardedBy("lock") private val runningRequests = HashMap<Uri, RequestRecord>() - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching) + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = + loadImageAsync(uri, caching) - override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { - callerScope.launch { - val image = loadImageAsync(uri, caching = true) - if (isActive) { - callback.accept(image) - } - } - } - - override fun prePopulate(uris: List<Uri>) { - uris.asSequence().take(cache.maxSize()).forEach { uri -> + override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) { + uriSizePairs.asSequence().take(cache.maxSize()).forEach { (uri, _) -> scope.launch { loadImageAsync(uri, caching = true) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 96bb8258..07cbaa04 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -24,10 +24,12 @@ import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL import android.provider.Downloads import android.provider.OpenableColumns +import android.service.chooser.Flags.chooserPayloadToggling import android.text.TextUtils import android.util.Log import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting +import com.android.intentresolver.Flags.individualMetadataTitleRead 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 @@ -54,14 +56,19 @@ import kotlinx.coroutines.withTimeoutOrNull * A set of metadata columns we read for a content URI (see * [PreviewDataProvider.UriRecord.readQueryResult] method). */ -@VisibleForTesting -val METADATA_COLUMNS = +private val METADATA_COLUMNS = arrayOf( DocumentsContract.Document.COLUMN_FLAGS, MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI, OpenableColumns.DISPLAY_NAME, - Downloads.Impl.COLUMN_TITLE + Downloads.Impl.COLUMN_TITLE, ) + +/** Preview-related metadata columns. */ +@VisibleForTesting +val ICON_METADATA_COLUMNS = + arrayOf(DocumentsContract.Document.COLUMN_FLAGS, MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI) + private const val TIMEOUT_MS = 1_000L /** @@ -76,9 +83,6 @@ constructor( 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, ) { @@ -129,7 +133,7 @@ constructor( * IMAGE, FILE, TEXT. */ if (!targetIntent.isSend || records.isEmpty()) { CONTENT_PREVIEW_TEXT - } else if (isPayloadTogglingEnabled && shouldShowPayloadSelection()) { + } else if (chooserPayloadToggling() && shouldShowPayloadSelection()) { // TODO: replace with the proper flags injection CONTENT_PREVIEW_PAYLOAD_SELECTION } else { @@ -142,7 +146,7 @@ constructor( Log.w( ContentPreviewUi.TAG, "An attempt to read preview type from a cancelled scope", - e + e, ) CONTENT_PREVIEW_FILE } @@ -160,7 +164,7 @@ constructor( Log.w( ContentPreviewUi.TAG, "Failed to check URI authorities; no payload toggling", - it + it, ) } .getOrDefault(false) @@ -184,7 +188,7 @@ constructor( Log.w( ContentPreviewUi.TAG, "An attempt to read first file info from a cancelled scope", - e + e, ) } builder.build() @@ -213,14 +217,20 @@ constructor( if (records.isEmpty()) { throw IndexOutOfBoundsException("There are no shared URIs") } - callerScope.launch { - val result = scope.async { getFirstFileName() }.await() - callback.accept(result) - } + callerScope.launch { callback.accept(getFirstFileName()) } + } + + /** + * Returns a title for the first shared URI which is read from URI metadata or, if the metadata + * is not provided, derived from the URI. + */ + @Throws(IndexOutOfBoundsException::class) + suspend fun getFirstFileName(): String { + return scope.async { getFirstFileNameInternal() }.await() } @Throws(IndexOutOfBoundsException::class) - private fun getFirstFileName(): String { + private fun getFirstFileNameInternal(): String { if (records.isEmpty()) throw IndexOutOfBoundsException("There are no shared URIs") val record = records[0] @@ -275,21 +285,31 @@ constructor( val mimeType: String? by lazy { contentResolver.getTypeSafe(uri) } val isImageType: Boolean get() = typeClassifier.isImageType(mimeType) + val supportsImageType: Boolean by lazy { contentResolver.getStreamTypesSafe(uri).firstOrNull(typeClassifier::isImageType) != null } val supportsThumbnail: Boolean get() = query.supportsThumbnail + val title: String - get() = query.title + get() = if (individualMetadataTitleRead()) titleFromQuery else query.title + val iconUri: Uri? get() = query.iconUri - private val query by lazy { readQueryResult() } + private val query by lazy { + readQueryResult( + if (individualMetadataTitleRead()) ICON_METADATA_COLUMNS else METADATA_COLUMNS + ) + } + + private val titleFromQuery by lazy { + readDisplayNameFromQuery().takeIf { !TextUtils.isEmpty(it) } ?: readTitleFromQuery() + } - private fun readQueryResult(): QueryResult = - // TODO: rewrite using methods from UiMetadataHelpers.kt - contentResolver.querySafe(uri, METADATA_COLUMNS)?.use { cursor -> + private fun readQueryResult(columns: Array<String>): QueryResult = + contentResolver.querySafe(uri, columns)?.use { cursor -> if (!cursor.moveToFirst()) return@use null var flagColIdx = -1 @@ -326,14 +346,24 @@ constructor( } QueryResult(supportsThumbnail, title, iconUri) - } - ?: QueryResult() + } ?: QueryResult() + + private fun readTitleFromQuery(): String = readStringColumn(Downloads.Impl.COLUMN_TITLE) + + private fun readDisplayNameFromQuery(): String = + readStringColumn(OpenableColumns.DISPLAY_NAME) + + private fun readStringColumn(column: String): String = + contentResolver.querySafe(uri, arrayOf(column))?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + cursor.readString(column) + } ?: "" } private class QueryResult( val supportsThumbnail: Boolean = false, val title: String = "", - val iconUri: Uri? = null + val iconUri: Uri? = null, ) } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt new file mode 100644 index 00000000..b10f7ef9 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt @@ -0,0 +1,197 @@ +/* + * 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.graphics.Bitmap +import android.net.Uri +import android.util.Log +import android.util.Size +import androidx.collection.lruCache +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import javax.annotation.concurrent.GuardedBy +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +private const val TAG = "PayloadSelImageLoader" + +/** + * Implements preview image loading for the payload selection UI. Cancels preview loading for items + * that has been evicted from the cache at the expense of a possible request duplication (deemed + * unlikely). + */ +class PreviewImageLoader +@Inject +constructor( + @ViewModelOwned private val scope: CoroutineScope, + @PreviewCacheSize private val cacheSize: Int, + @ThumbnailSize private val defaultPreviewSize: Int, + private val thumbnailLoader: ThumbnailLoader, + @Background private val bgDispatcher: CoroutineDispatcher, + @PreviewMaxConcurrency maxSimultaneousRequests: Int = 4, +) : ImageLoader { + + private val contentResolverSemaphore = Semaphore(maxSimultaneousRequests) + + private val lock = Any() + @GuardedBy("lock") private val runningRequests = hashMapOf<Uri, RequestRecord>() + @GuardedBy("lock") + private val cache = + lruCache<Uri, RequestRecord>( + maxSize = cacheSize, + onEntryRemoved = { _, _, oldRec, newRec -> + if (oldRec !== newRec) { + onRecordEvictedFromCache(oldRec) + } + } + ) + + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = + loadImageInternal(uri, size, caching) + + override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) { + uriSizePairs.asSequence().take(cacheSize).forEach { uri -> + scope.launch { loadImageInternal(uri.first, uri.second, caching = true) } + } + } + + private suspend fun loadImageInternal(uri: Uri, size: Size, caching: Boolean): Bitmap? { + return withRequestRecord(uri, caching) { record -> + val newSize = sanitize(size) + val newMetric = newSize.metric + record + .also { + // set the requested size to the max of the new and the previous value; input + // will emit if the resulted value is greater than the old one + it.input.update { oldSize -> + if (oldSize == null || oldSize.metric < newSize.metric) newSize else oldSize + } + } + .output + // filter out bitmaps of a lower resolution than that we're requesting + .filter { it is BitmapLoadingState.Loaded && newMetric <= it.size.metric } + .firstOrNull() + ?.let { (it as BitmapLoadingState.Loaded).bitmap } + } + } + + private suspend fun withRequestRecord( + uri: Uri, + caching: Boolean, + block: suspend (RequestRecord) -> Bitmap? + ): Bitmap? { + val record = trackRecordRunning(uri, caching) + return try { + block(record) + } finally { + untrackRecordRunning(uri, record) + } + } + + private fun trackRecordRunning(uri: Uri, caching: Boolean): RequestRecord = + synchronized(lock) { + runningRequests + .getOrPut(uri) { cache[uri] ?: createRecord(uri) } + .also { record -> + record.clientCount++ + if (caching) { + cache.put(uri, record) + } + } + } + + private fun untrackRecordRunning(uri: Uri, record: RequestRecord) { + synchronized(lock) { + record.clientCount-- + if (record.clientCount <= 0) { + runningRequests.remove(uri) + val result = record.output.value + if (cache[uri] == null) { + record.loadingJob.cancel() + } else if (result is BitmapLoadingState.Loaded && result.bitmap == null) { + cache.remove(uri) + } + } + } + } + + private fun onRecordEvictedFromCache(record: RequestRecord) { + synchronized(lock) { + if (record.clientCount <= 0) { + record.loadingJob.cancel() + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun createRecord(uri: Uri): RequestRecord { + // use a StateFlow with sentinel values to avoid using SharedFlow that is deemed dangerous + val input = MutableStateFlow<Size?>(null) + val output = MutableStateFlow<BitmapLoadingState>(BitmapLoadingState.Loading) + val job = + scope.launch(bgDispatcher) { + // the image loading pipeline: input -- a desired image size, output -- a bitmap + input + .filterNotNull() + .mapLatest { size -> BitmapLoadingState.Loaded(size, loadBitmap(uri, size)) } + .collect { output.tryEmit(it) } + } + return RequestRecord(input, output, job, clientCount = 0) + } + + private suspend fun loadBitmap(uri: Uri, size: Size): Bitmap? = + contentResolverSemaphore.withPermit { + runCatching { thumbnailLoader.loadThumbnail(uri, size) } + .onFailure { Log.d(TAG, "failed to load $uri preview", it) } + .getOrNull() + } + + private class RequestRecord( + /** The image loading pipeline input: desired preview size */ + val input: MutableStateFlow<Size?>, + /** The image loading pipeline output */ + val output: MutableStateFlow<BitmapLoadingState>, + /** The image loading pipeline job */ + val loadingJob: Job, + @GuardedBy("lock") var clientCount: Int, + ) + + private sealed interface BitmapLoadingState { + data object Loading : BitmapLoadingState + + data class Loaded(val size: Size, val bitmap: Bitmap?) : BitmapLoadingState + } + + private fun sanitize(size: Size?): Size = + size?.takeIf { it.width > 0 && it.height > 0 } + ?: Size(defaultPreviewSize, defaultPreviewSize) +} + +private val Size.metric + get() = maxOf(width, height) diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt deleted file mode 100644 index 6a729945..00000000 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ /dev/null @@ -1,98 +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.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.R -import com.android.intentresolver.inject.Background -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.plus - -/** 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 targetIntent: Intent? = null - private var additionalContentUri: Uri? = null - private var isPayloadTogglingEnabled = false - - 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 - ) - } - - // TODO: make the view model injectable and inject these dependencies instead - @MainThread - override fun init( - targetIntent: Intent, - additionalContentUri: Uri?, - isPayloadTogglingEnabled: Boolean, - ) { - if (this.targetIntent != null) return - this.targetIntent = targetIntent - this.additionalContentUri = additionalContentUri - this.isPayloadTogglingEnabled = isPayloadTogglingEnabled - } - - companion object { - val Factory: ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create( - modelClass: Class<T>, - extras: CreationExtras - ): 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/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 57a51239..ff52556a 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.launch @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class ShareouselContentPreviewUi : ContentPreviewUi() { - override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE + override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION override fun display( resources: Resources, diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index ae7ddcd9..b12eb8cf 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -22,6 +22,7 @@ import android.content.res.Resources; import android.net.Uri; import android.text.SpannableStringBuilder; import android.text.TextUtils; +import android.util.Size; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -50,6 +51,7 @@ class TextContentPreviewUi extends ContentPreviewUi { private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final HeadlineGenerator mHeadlineGenerator; private final ContentTypeHint mContentTypeHint; + private int mPreviewSize; TextContentPreviewUi( CoroutineScope scope, @@ -83,6 +85,7 @@ class TextContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) { + mPreviewSize = resources.getDimensionPixelSize(R.dimen.width_text_image_preview_size); return displayInternal(layoutInflater, parent, headlineViewParent); } @@ -119,7 +122,7 @@ class TextContentPreviewUi extends ContentPreviewUi { previewTitleView.setText(mPreviewTitle); } - ImageView previewThumbnailView = contentPreviewLayout.findViewById( + final ImageView previewThumbnailView = contentPreviewLayout.requireViewById( com.android.internal.R.id.content_preview_thumbnail); if (!isOwnedByCurrentUser(mPreviewThumbnail)) { previewThumbnailView.setVisibility(View.GONE); @@ -127,9 +130,9 @@ class TextContentPreviewUi extends ContentPreviewUi { mImageLoader.loadImage( mScope, mPreviewThumbnail, + new Size(mPreviewSize, mPreviewSize), (bitmap) -> updateViewWithImage( - contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail), + previewThumbnailView, bitmap)); } diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt index 9f1d50da..e8afa480 100644 --- a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt @@ -20,10 +20,25 @@ import android.content.ContentResolver import android.graphics.Bitmap import android.net.Uri import android.util.Size +import com.android.intentresolver.util.withCancellationSignal import javax.inject.Inject /** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */ -interface ThumbnailLoader : suspend (Uri) -> Bitmap? +interface ThumbnailLoader { + /** + * Loads a thumbnail for the given [uri]. + * + * The size of the thumbnail is determined by the implementation. + */ + suspend fun loadThumbnail(uri: Uri): Bitmap? + + /** + * Loads a thumbnail for the given [uri] and [size]. + * + * The [size] is the size of the thumbnail in pixels. + */ + suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap? +} /** Default implementation of [ThumbnailLoader]. */ class ThumbnailLoaderImpl @@ -35,6 +50,11 @@ constructor( private val size = Size(thumbnailSize, thumbnailSize) - override suspend fun invoke(uri: Uri): Bitmap = - contentResolver.loadThumbnail(uri, size, /* signal = */ null) + override suspend fun loadThumbnail(uri: Uri): Bitmap = + contentResolver.loadThumbnail(uri, size, /* signal= */ null) + + override suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap = + withCancellationSignal { signal -> + contentResolver.loadThumbnail(uri, size, signal) + } } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 88311016..7de988c4 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -20,6 +20,7 @@ import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTE import android.content.res.Resources; import android.util.Log; +import android.util.Size; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -31,6 +32,8 @@ import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import com.android.intentresolver.widget.ScrollableImagePreviewView; +import kotlin.Pair; + import kotlinx.coroutines.CoroutineScope; import kotlinx.coroutines.flow.Flow; @@ -55,6 +58,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { @Nullable private ViewGroup mContentPreviewView; private View mHeadlineView; + private int mPreviewSize; UnifiedContentPreviewUi( CoroutineScope scope, @@ -93,14 +97,18 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) { + mPreviewSize = resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen); return displayInternal(layoutInflater, parent, headlineViewParent); } private void setFiles(List<FileInfo> files) { - mImageLoader.prePopulate(files.stream() - .map(FileInfo::getPreviewUri) - .filter(Objects::nonNull) - .toList()); + Size previewSize = new Size(mPreviewSize, mPreviewSize); + mImageLoader.prePopulate( + files.stream() + .map(FileInfo::getPreviewUri) + .filter(Objects::nonNull) + .map((uri -> new Pair<>(uri, previewSize))) + .toList()); mFiles = files; if (mContentPreviewView != null) { updatePreviewWithFiles(mContentPreviewView, mHeadlineView, files); @@ -121,6 +129,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { ScrollableImagePreviewView imagePreview = mContentPreviewView.requireViewById(R.id.scrollable_image_preview); + imagePreview.setPreviewHeight(mPreviewSize); imagePreview.setImageLoader(mImageLoader); imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE)); imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt index c532b9a5..80d0e058 100644 --- a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt @@ -22,11 +22,8 @@ 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.MediaStore.MediaColumns.HEIGHT import android.provider.MediaStore.MediaColumns.WIDTH -import android.provider.OpenableColumns -import android.text.TextUtils import android.util.Log import android.util.Size import com.android.intentresolver.measurements.runTracing @@ -78,12 +75,7 @@ internal fun Cursor.readSupportsThumbnail(): Boolean = .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) } - } + runCatching { readString(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI)?.let(Uri::parse) } .getOrNull() fun Cursor.readSize(): Size? { @@ -105,34 +97,15 @@ fun Cursor.readSize(): Size? { } } -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("") +internal fun Cursor.readString(columnName: String): String? = + runCatching { columnNames.indexOf(columnName).takeIf { it >= 0 }?.let { getString(it) } } + .getOrNull() 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." + " ensure that the sharesheet is given permission.", ) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt index 81c56d1e..0688ce02 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -18,12 +18,12 @@ package com.android.intentresolver.contentpreview.payloadtoggle.data.repository import android.net.Uri import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel -import dagger.hilt.android.scopes.ViewModelScoped +import dagger.hilt.android.scopes.ActivityRetainedScoped import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow /** Stores set of selected previews. */ -@ViewModelScoped +@ActivityRetainedScoped class PreviewSelectionsRepository @Inject constructor() { val selections = MutableStateFlow(emptyMap<Uri, PreviewModel>()) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt index 148310e6..2b14cdea 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt @@ -20,6 +20,8 @@ import android.content.ContentInterface import android.content.Intent import android.database.Cursor import android.net.Uri +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH import android.service.chooser.AdditionalContentContract.Columns.URI import androidx.core.os.bundleOf import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow @@ -48,8 +50,7 @@ constructor( runCatching { contentResolver.query( cursorUri, - // TODO: uncomment to start using that data - arrayOf(URI /*, WIDTH, HEIGHT*/), + arrayOf(URI, WIDTH, HEIGHT), bundleOf(Intent.EXTRA_INTENT to chooserIntent), signal, ) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt index a475263c..7d658209 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -20,6 +20,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.net.Uri import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION +import android.util.Log import com.android.intentresolver.contentpreview.UriMetadataReader import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection @@ -51,6 +52,8 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest +private const val TAG = "CursorPreviewsIntr" + /** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */ class CursorPreviewsInteractor @Inject @@ -273,8 +276,7 @@ constructor( pagedCursor .getPageRows(pageNum) // TODO: what do we do if the load fails? ?.filter { it.uri !in state.merged } - ?.toPage(this, unclaimedRecords) - ?: this + ?.toPage(this, unclaimedRecords) ?: this private suspend fun <M : MutablePreviewMap> Sequence<CursorRow>.toPage( destination: M, @@ -288,26 +290,32 @@ constructor( private fun createPreviewModel( row: CursorRow, unclaimedRecords: MutableUnclaimedMap, - ): PreviewModel = uriMetadataReader.getMetadata(row.uri).let { metadata -> - val size = - row.previewSize - ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } - PreviewModel( - uri = row.uri, - previewUri = metadata.previewUri, - mimeType = metadata.mimeType, - aspectRatio = size.aspectRatioOrDefault(1f), - order = row.position, - ) - }.also { updated -> - if (unclaimedRecords.remove(row.uri) != null) { - // unclaimedRecords contains initially shared (and thus selected) items with unknown - // cursor position. Update selection records when any of those items is encountered - // in the cursor to maintain proper selection order should other items also be - // selected. - selectionInteractor.updateSelection(updated) + ): PreviewModel = + uriMetadataReader + .getMetadata(row.uri) + .let { metadata -> + val size = + row.previewSize + ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } + PreviewModel( + uri = row.uri, + previewUri = metadata.previewUri, + mimeType = metadata.mimeType, + aspectRatio = size.aspectRatioOrDefault(1f), + order = row.position, + ) + } + .also { updated -> + if (unclaimedRecords.remove(row.uri) != null) { + // unclaimedRecords contains initially shared (and thus selected) items with + // unknown + // cursor position. Update selection records when any of those items is + // encountered + // in the cursor to maintain proper selection order should other items also be + // selected. + selectionInteractor.updateSelection(updated) + } } - } private fun <M : MutablePreviewMap> M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx } @@ -343,7 +351,28 @@ private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere( .toMap(this) private fun PagedCursor<CursorRow?>.getPageRows(pageNum: Int): Sequence<CursorRow>? = - get(pageNum)?.filterNotNull() + runCatching { get(pageNum) } + .onFailure { Log.e(TAG, "Failed to read additional content cursor page #$pageNum", it) } + .getOrNull() + ?.asSafeSequence() + ?.filterNotNull() + +private fun <T> Sequence<T>.asSafeSequence(): Sequence<T> { + return if (this is SafeSequence) this else SafeSequence(this) +} + +private class SafeSequence<T>(private val sequence: Sequence<T>) : Sequence<T> { + override fun iterator(): Iterator<T> = + sequence.iterator().let { if (it is SafeIterator) it else SafeIterator(it) } +} + +private class SafeIterator<T>(private val iterator: Iterator<T>) : Iterator<T> by iterator { + override fun hasNext(): Boolean { + return runCatching { iterator.hasNext() } + .onFailure { Log.e(TAG, "Failed to read cursor", it) } + .getOrDefault(false) + } +} @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt index d52a71a1..8f18ebe0 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.net.Uri import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.logging.EventLog import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.map class SelectablePreviewInteractor( private val key: PreviewModel, private val selectionInteractor: SelectionInteractor, + private val eventLog: EventLog, ) { val uri: Uri = key.uri @@ -33,6 +35,7 @@ class SelectablePreviewInteractor( /** Sets whether this preview is selected by the user. */ fun setSelected(isSelected: Boolean) { + eventLog.logPayloadSelectionChanged() if (isSelected) { selectionInteractor.select(key) } else { diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt index a578d0e2..d0ac8d4a 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt @@ -19,6 +19,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.logging.EventLog import javax.inject.Inject import kotlinx.coroutines.flow.Flow @@ -27,6 +28,7 @@ class SelectablePreviewsInteractor constructor( private val previewsRepo: CursorPreviewsRepository, private val selectionInteractor: SelectionInteractor, + private val eventLog: EventLog, ) { /** Keys of previews available for display in Shareousel. */ val previews: Flow<PreviewsModel?> @@ -36,5 +38,5 @@ constructor( * Returns a [SelectablePreviewInteractor] that can be used to interact with the individual * preview associated with [key]. */ - fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor) + fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor, eventLog) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt index 97d9fa66..2d02e4fd 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import android.net.Uri +import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.contentpreview.MimeTypeClassifier import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier @@ -60,8 +61,12 @@ constructor( } fun unselect(model: PreviewModel) { - if (selectionsRepo.selections.value.size > 1) { - updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model.uri }.values) + if (selectionsRepo.selections.value.size > 1 || unselectFinalItem()) { + selectionsRepo.selections + .updateAndGet { it - model.uri } + .values + .takeIf { it.isNotEmpty() } + ?.let { updateChooserRequest(it) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt index dd16f0c1..4fe5e8d5 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import android.content.Intent +import com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.CustomAction import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel @@ -49,6 +50,12 @@ constructor( update.refinementIntentSender.getOrDefault(current.refinementIntentSender), metadataText = update.metadataText.getOrDefault(current.metadataText), chooserActions = update.customActions.getOrDefault(current.chooserActions), + filteredComponentNames = + if (shareouselUpdateExcludeComponentsExtra()) { + update.excludeComponents.getOrDefault(current.filteredComponentNames) + } else { + current.filteredComponentNames + } ) } update.customActions.onValue { actions -> diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt index 821e88a5..77f196e6 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.model +import android.content.ComponentName import android.content.Intent import android.content.IntentSender import android.service.chooser.ChooserAction @@ -31,4 +32,5 @@ data class ShareouselUpdate( val refinementIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent, val resultIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent, val metadataText: ValueUpdate<CharSequence?> = ValueUpdate.Absent, + val excludeComponents: ValueUpdate<List<ComponentName>> = ValueUpdate.Absent, ) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt index 1d34dc75..184cc027 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.update +import android.content.ComponentName import android.content.ContentInterface import android.content.Intent import android.content.Intent.EXTRA_ALTERNATE_INTENTS @@ -24,6 +25,7 @@ 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_EXCLUDE_COMPONENTS import android.content.Intent.EXTRA_INTENT import android.content.Intent.EXTRA_METADATA_TEXT import android.content.IntentSender @@ -32,11 +34,11 @@ 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.Flags.shareouselUpdateExcludeComponentsExtra import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.inject.AdditionalContent import com.android.intentresolver.inject.ChooserIntent -import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.ui.viewmodel.readAlternateIntents import com.android.intentresolver.ui.viewmodel.readChooserActions import com.android.intentresolver.validation.Invalid @@ -70,7 +72,6 @@ constructor( @AdditionalContent private val uri: Uri, @ChooserIntent private val chooserIntent: Intent, private val contentResolver: ContentInterface, - private val flags: ChooserServiceFlags, ) : SelectionChangeCallback { private val mutex = Mutex() @@ -90,7 +91,7 @@ constructor( ) } ?.let { bundle -> - return when (val result = readCallbackResponse(bundle, flags)) { + return when (val result = readCallbackResponse(bundle)) { is Valid -> { result.warnings.forEach { it.log(TAG) } result.value @@ -105,7 +106,6 @@ constructor( private fun readCallbackResponse( bundle: Bundle, - flags: ChooserServiceFlags ): ValidationResult<ShareouselUpdate> { return validateFrom(bundle::get) { // An error is treated as an empty collection or null as the presence of a value indicates @@ -136,9 +136,13 @@ private fun readCallbackResponse( optional(value<IntentSender>(key)) } val metadataText = - if (flags.enableSharesheetMetadataExtra()) { - bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key -> - optional(value<CharSequence>(key)) + bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key -> + optional(value<CharSequence>(key)) + } + val excludedComponents: ValueUpdate<List<ComponentName>> = + if (shareouselUpdateExcludeComponentsExtra()) { + bundle.readValueUpdate(EXTRA_EXCLUDE_COMPONENTS) { key -> + optional(array<ComponentName>(key)) ?: emptyList() } } else { ValueUpdate.Absent @@ -152,6 +156,7 @@ private fun readCallbackResponse( refinementIntentSender, resultIntentSender, metadataText, + excludedComponents, ) } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index c40ed266..4b87d227 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -44,21 +45,27 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.intentresolver.Flags.shareouselScrollOffscreenSelections +import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.R import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault @@ -67,6 +74,8 @@ import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.Prev import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel import kotlin.math.abs +import kotlin.math.min +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @Composable @@ -100,48 +109,158 @@ private fun PreviewCarousel( previews: PreviewsModel, viewModel: ShareouselViewModel, ) { - val centerIdx = previews.startIdx - val carouselState = - rememberLazyListState( - initialFirstVisibleItemIndex = centerIdx, - prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() } - ) - // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if - // HorizontalPager works for our use-case - LazyRow( - state = carouselState, - horizontalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp), + var maxAspectRatio by remember { mutableStateOf(0f) } + var viewportHeight by remember { mutableStateOf(0) } + var viewportCenter by remember { mutableStateOf(0) } + var horizontalPadding by remember { mutableStateOf(0.dp) } + Box( modifier = Modifier.fillMaxWidth() .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) - .systemGestureExclusion() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val (minItemWidth, maxAR) = + if (placeable.height <= 0) { + 0f to 0f + } else { + val minItemWidth = (MIN_ASPECT_RATIO * placeable.height) + val maxItemWidth = maxOf(0, placeable.width - 32.dp.roundToPx()) + val maxAR = + (maxItemWidth.toFloat() / placeable.height).coerceIn( + 0f, + MAX_ASPECT_RATIO + ) + minItemWidth to maxAR + } + viewportCenter = placeable.width / 2 + maxAspectRatio = maxAR + viewportHeight = placeable.height + horizontalPadding = ((placeable.width - minItemWidth) / 2).toDp() + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + }, ) { - itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model -> + if (maxAspectRatio <= 0 && previews.previewModels.isNotEmpty()) { + // Do not compose the list until we know the viewport size + return@Box + } + + var firstSelectedIndex by remember { mutableStateOf(null as Int?) } + + val carouselState = + rememberLazyListState( + prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() }, + ) - // Index if this is the element in the center of the viewing area, otherwise null - val previewIndex by remember { - derivedStateOf { - carouselState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == index } - ?.let { - val viewportCenter = carouselState.layoutInfo.viewportEndOffset / 2 + LazyRow( + state = carouselState, + horizontalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding), + modifier = Modifier.fillMaxSize().systemGestureExclusion(), + ) { + itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model -> + val visibleItem by remember { + derivedStateOf { + carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + } + } + + // Index if this is the element in the center of the viewing area, otherwise null + val previewIndex by remember { + derivedStateOf { + visibleItem?.let { val halfPreviewWidth = it.size / 2 val previewCenter = it.offset + halfPreviewWidth val previewDistanceToViewportCenter = abs(previewCenter - viewportCenter) - if (previewDistanceToViewportCenter <= halfPreviewWidth) index else null + if (previewDistanceToViewportCenter <= halfPreviewWidth) { + index + } else { + null + } + } + } + } + + val previewModel = + viewModel.preview(model, viewportHeight, previewIndex, rememberCoroutineScope()) + val selected by + previewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) + + if (selected) { + firstSelectedIndex = min(index, firstSelectedIndex ?: Int.MAX_VALUE) + } + + if (shareouselScrollOffscreenSelections()) { + LaunchedEffect(index, model.uri) { + var current: Boolean? = null + previewModel.isSelected.collect { selected -> + when { + // First update will always be the current state, so we just want to + // record the state and do nothing else. + current == null -> current = selected + + // We only want to act when the state changes + current != selected -> { + current = selected + with(carouselState.layoutInfo) { + visibleItemsInfo + .firstOrNull { it.index == index } + ?.let { item -> + when { + // Item is partially past start of viewport + item.offset < viewportStartOffset -> + -viewportStartOffset + // Item is partially past end of viewport + (item.offset + item.size) > viewportEndOffset -> + item.size - viewportEndOffset + // Item is fully within viewport + else -> null + }?.let { scrollOffset -> + carouselState.animateScrollToItem( + index = index, + scrollOffset = scrollOffset, + ) + } + } + } + } + } } + } } + + ShareouselCard( + viewModel.preview( + model, + viewportHeight, + previewIndex, + rememberCoroutineScope() + ), + maxAspectRatio, + ) } + } + + firstSelectedIndex?.let { index -> + LaunchedEffect(Unit) { + val visibleItem = + carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + val center = + with(carouselState.layoutInfo) { + ((viewportEndOffset - viewportStartOffset) / 2) + viewportStartOffset + } - ShareouselCard(viewModel.preview(model, previewIndex, rememberCoroutineScope())) + carouselState.scrollToItem( + index = index, + scrollOffset = visibleItem?.size?.div(2)?.minus(center) ?: 0, + ) + } } } } @Composable -private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { +private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, maxAspectRatio: Float) { val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle() val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) val borderColor = MaterialTheme.colorScheme.primary @@ -162,8 +281,7 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { onValueChange = { scope.launch { viewModel.setSelected(it) } }, ) ) { state -> - // TODO: max ratio is actually equal to the viewport ratio - val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + val aspectRatio = minOf(maxAspectRatio, maxOf(MIN_ASPECT_RATIO, viewModel.aspectRatio)) if (state is ValueUpdate.Value) { state.getOrDefault(null).let { bitmap -> ShareouselCard( @@ -210,30 +328,46 @@ private fun ActionCarousel(viewModel: ShareouselViewModel) { val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) if (actions.isNotEmpty()) { Spacer(Modifier.height(16.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.height(32.dp), - ) { - itemsIndexed(actions) { idx, actionViewModel -> - if (idx == 0) { - Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) - } - ShareouselAction( - label = actionViewModel.label, - onClick = { actionViewModel.onClicked() }, - ) { - actionViewModel.icon?.let { - Image( - icon = it, - modifier = Modifier.size(16.dp), - colorFilter = ColorFilter.tint(LocalContentColor.current) + val visibilityFlow = + if (unselectFinalItem()) { + viewModel.hasSelectedItems + } else { + MutableStateFlow(true) + } + val visibility by visibilityFlow.collectAsStateWithLifecycle(true) + val height = 32.dp + if (visibility) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.height(height), + ) { + itemsIndexed(actions) { idx, actionViewModel -> + if (idx == 0) { + Spacer( + Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal)) + ) + } + ShareouselAction( + label = actionViewModel.label, + onClick = { actionViewModel.onClicked() }, + ) { + actionViewModel.icon?.let { + Image( + icon = it, + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ) + } + } + if (idx == actions.size - 1) { + Spacer( + Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal)) ) } - } - if (idx == actions.size - 1) { - Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) } } + } else { + Spacer(modifier = Modifier.height(height)) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt index d0b89860..ebcd58d1 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -15,10 +15,14 @@ */ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel +import android.util.Size +import com.android.intentresolver.Flags +import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader import com.android.intentresolver.contentpreview.HeadlineGenerator import com.android.intentresolver.contentpreview.ImageLoader import com.android.intentresolver.contentpreview.MimeTypeClassifier +import com.android.intentresolver.contentpreview.PreviewImageLoader import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor @@ -29,14 +33,15 @@ import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentTyp import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.inject.ViewModelOwned -import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Provider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -55,95 +60,123 @@ data class ShareouselViewModel( val previews: Flow<PreviewsModel?>, /** List of action chips presented underneath Shareousel. */ val actions: Flow<List<ActionChipViewModel>>, + /** Indicates whether there are any selected items */ + val hasSelectedItems: Flow<Boolean>, /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ val preview: - (key: PreviewModel, index: Int?, scope: CoroutineScope) -> ShareouselPreviewViewModel, + ( + key: PreviewModel, previewHeight: Int, index: Int?, scope: CoroutineScope + ) -> ShareouselPreviewViewModel, ) @Module @InstallIn(ViewModelComponent::class) -interface ShareouselViewModelModule { +object ShareouselViewModelModule { - @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader + @Provides + @PayloadToggle + fun imageLoader( + cachingImageLoader: Provider<CachingImagePreviewImageLoader>, + previewImageLoader: Provider<PreviewImageLoader> + ): ImageLoader = + if (Flags.previewImageLoader()) { + previewImageLoader.get() + } else { + cachingImageLoader.get() + } - companion object { - @Provides - fun create( - interactor: SelectablePreviewsInteractor, - @PayloadToggle imageLoader: ImageLoader, - actionsInteractor: CustomActionsInteractor, - headlineGenerator: HeadlineGenerator, - selectionInteractor: SelectionInteractor, - chooserRequestInteractor: ChooserRequestInteractor, - mimeTypeClassifier: MimeTypeClassifier, - // TODO: remove if possible - @ViewModelOwned scope: CoroutineScope, - ): ShareouselViewModel { - val keySet = - interactor.previews.stateIn( - scope, - SharingStarted.Eagerly, - initialValue = null, - ) - return ShareouselViewModel( - headline = - selectionInteractor.aggregateContentType.zip( - selectionInteractor.amountSelected - ) { contentType, numItems -> + @Provides + fun create( + interactor: SelectablePreviewsInteractor, + @PayloadToggle imageLoader: ImageLoader, + actionsInteractor: CustomActionsInteractor, + headlineGenerator: HeadlineGenerator, + selectionInteractor: SelectionInteractor, + chooserRequestInteractor: ChooserRequestInteractor, + mimeTypeClassifier: MimeTypeClassifier, + // TODO: remove if possible + @ViewModelOwned scope: CoroutineScope, + ): ShareouselViewModel { + val keySet = + interactor.previews.stateIn( + scope, + SharingStarted.Eagerly, + initialValue = null, + ) + return ShareouselViewModel( + headline = + selectionInteractor.aggregateContentType.zip(selectionInteractor.amountSelected) { + contentType, + numItems -> + if (unselectFinalItem() && numItems == 0) { + headlineGenerator.getNotItemsSelectedHeadline() + } else { when (contentType) { ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) } - }, - metadataText = chooserRequestInteractor.metadataText, - previews = keySet, - actions = - actionsInteractor.customActions.map { actions -> - actions.mapIndexedNotNull { i, model -> - val icon = model.icon - val label = model.label - if (icon == null && label.isBlank()) { - null - } else { - ActionChipViewModel( - label = label.toString(), - icon = model.icon, - onClicked = { model.performAction(i) }, - ) - } - } - }, - preview = { key, index, previewScope -> - keySet.value?.maybeLoad(index) - val previewInteractor = interactor.preview(key) - val contentType = - when { - mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image - mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video - else -> ContentType.Other + } + }, + metadataText = chooserRequestInteractor.metadataText, + previews = keySet, + actions = + actionsInteractor.customActions.map { actions -> + actions.mapIndexedNotNull { i, model -> + val icon = model.icon + val label = model.label + if (icon == null && label.isBlank()) { + null + } else { + ActionChipViewModel( + label = label.toString(), + icon = model.icon, + onClicked = { model.performAction(i) }, + ) } - val initialBitmapValue = - key.previewUri?.let { - imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) } - } ?: ValueUpdate.Absent - ShareouselPreviewViewModel( - bitmapLoadState = - flow { - emit( - key.previewUri?.let { ValueUpdate.Value(imageLoader(it)) } - ?: ValueUpdate.Absent - ) - } - .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), - contentType = contentType, - isSelected = previewInteractor.isSelected, - setSelected = previewInteractor::setSelected, - aspectRatio = key.aspectRatio, - ) + } }, - ) - } + hasSelectedItems = + selectionInteractor.selections.map { it.isNotEmpty() }.distinctUntilChanged(), + preview = { key, previewHeight, index, previewScope -> + keySet.value?.maybeLoad(index) + val previewInteractor = interactor.preview(key) + val contentType = + when { + mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image + mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video + else -> ContentType.Other + } + val initialBitmapValue = + key.previewUri?.let { + imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) } + } ?: ValueUpdate.Absent + ShareouselPreviewViewModel( + bitmapLoadState = + flow { + val previewWidth = + if (key.aspectRatio > 0) { + previewHeight.toFloat() / key.aspectRatio + } else { + previewHeight + } + .toInt() + emit( + key.previewUri?.let { + ValueUpdate.Value( + imageLoader(it, Size(previewWidth, previewHeight)) + ) + } ?: ValueUpdate.Absent + ) + } + .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), + contentType = contentType, + isSelected = previewInteractor.isSelected, + setSelected = previewInteractor::setSelected, + aspectRatio = key.aspectRatio, + ) + }, + ) } } diff --git a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt index 045a17f6..c4aa2b98 100644 --- a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt +++ b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt @@ -156,6 +156,8 @@ data class ChooserRequest( * TODO: Constrain length? */ val sharedText: CharSequence? = null, + /** Contains title to the text content to share supplied by the source app. */ + val sharedTextTitle: CharSequence? = null, /** * Supplied to diff --git a/java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt b/java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt new file mode 100644 index 00000000..7c3188d2 --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import com.android.intentresolver.shared.model.ActivityModel +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.atomicfu.atomic + +/** An [ActivityModel] repository that captures the first value. */ +@ActivityRetainedScoped +class ActivityModelRepository @Inject constructor() { + private val _value = atomic<ActivityModel?>(null) + + val value: ActivityModel + get() = requireNotNull(_value.value) { "Repository has not been initialized" } + + fun initialize(block: () -> ActivityModel) { + if (_value.value == null) { + _value.compareAndSet(null, block()) + } + } +} diff --git a/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt index 2ba08c90..5635ec28 100644 --- a/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt +++ b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt @@ -32,3 +32,9 @@ fun CreationExtras.addDefaultArgs(vararg values: Pair<String, Parcelable>): Crea defaultArgs.putAll(bundleOf(*values)) return MutableCreationExtras(this).apply { set(DEFAULT_ARGS_KEY, defaultArgs) } } + +fun CreationExtras.replaceDefaultArgs(vararg values: Pair<String, Parcelable>): CreationExtras { + val mutableExtras = if (this is MutableCreationExtras) this else MutableCreationExtras(this) + mutableExtras[DEFAULT_ARGS_KEY] = bundleOf(*values) + return mutableExtras +} diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 7cf9d2e9..9a50d7e4 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -88,7 +88,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. private final int mMaxTargetsPerRow; private final boolean mShouldShowContentPreview; - private final int mChooserWidthPixels; private final int mChooserRowTextOptionTranslatePixelSize; private final FeatureFlags mFeatureFlags; @Nullable @@ -117,7 +116,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. mShouldShowContentPreview = shouldShowContentPreview; mMaxTargetsPerRow = maxTargetsPerRow; - mChooserWidthPixels = context.getResources().getDimensionPixelSize(R.dimen.chooser_width); mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize( R.dimen.chooser_row_text_option_translate); mFeatureFlags = featureFlags; @@ -150,11 +148,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. public void setFooterHeight(int 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); - } + // we always have at least one view, the footer, see getItemCount() and + // getFooterRowCount() + notifyItemChanged(getItemCount() - 1); } } @@ -169,11 +165,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. return false; } - // Limit width to the maximum width of the chooser activity, if the maximum width is set - if (mChooserWidthPixels >= 0) { - width = Math.min(mChooserWidthPixels, width); - } - int newWidth = width / mMaxTargetsPerRow; if (newWidth != mChooserTargetWidth) { mChooserTargetWidth = newWidth; diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt index bbd25eb7..7201bd2b 100644 --- a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -19,9 +19,8 @@ package com.android.intentresolver.inject import android.content.Intent import android.net.Uri import android.service.chooser.ChooserAction -import androidx.lifecycle.SavedStateHandle import com.android.intentresolver.data.model.ChooserRequest -import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.data.repository.ActivityModelRepository import com.android.intentresolver.ui.viewmodel.readChooserRequest import com.android.intentresolver.util.ownedByCurrentUser import com.android.intentresolver.validation.Valid @@ -37,26 +36,19 @@ import javax.inject.Qualifier @InstallIn(ViewModelComponent::class) object ActivityModelModule { @Provides - fun provideActivityModel(savedStateHandle: SavedStateHandle): ActivityModel = - requireNotNull(savedStateHandle[ActivityModel.ACTIVITY_MODEL_KEY]) { - "ActivityModel missing in SavedStateHandle! (${ActivityModel.ACTIVITY_MODEL_KEY})" - } - - @Provides @ChooserIntent - fun chooserIntent(activityModel: ActivityModel): Intent = activityModel.intent + fun chooserIntent(activityModelRepo: ActivityModelRepository): Intent = + activityModelRepo.value.intent @Provides @ViewModelScoped fun provideInitialRequest( - activityModel: ActivityModel, + activityModelRepo: ActivityModelRepository, flags: ChooserServiceFlags, - ): ValidationResult<ChooserRequest> = readChooserRequest(activityModel, flags) + ): ValidationResult<ChooserRequest> = readChooserRequest(activityModelRepo.value, flags) @Provides - fun provideChooserRequest( - initialRequest: ValidationResult<ChooserRequest>, - ): ChooserRequest = + fun provideChooserRequest(initialRequest: ValidationResult<ChooserRequest>): ChooserRequest = requireNotNull((initialRequest as? Valid)?.value) { "initialRequest is Invalid, no chooser request available" } diff --git a/java/src/com/android/intentresolver/logging/EventLog.kt b/java/src/com/android/intentresolver/logging/EventLog.kt index 476bd4bf..b92f0732 100644 --- a/java/src/com/android/intentresolver/logging/EventLog.kt +++ b/java/src/com/android/intentresolver/logging/EventLog.kt @@ -47,6 +47,7 @@ interface EventLog { ) fun logCustomActionSelected(positionPicked: Int) + fun logShareTargetSelected( targetType: Int, packageName: String?, @@ -60,15 +61,29 @@ interface EventLog { ) fun logDirectShareTargetReceived(category: Int, latency: Int) + fun logActionShareWithPreview(previewType: Int) + fun logActionSelected(targetType: Int) + fun logContentPreviewWarning(uri: Uri?) + fun logSharesheetTriggered() + fun logSharesheetAppLoadComplete() + fun logSharesheetDirectLoadComplete() + fun logSharesheetDirectLoadTimeout() + fun logSharesheetProfileChanged() + fun logSharesheetExpansionChanged(isCollapsed: Boolean) + fun logSharesheetAppShareRankingTimeout() + fun logSharesheetEmptyDirectShareRow() + + /** Log payload selection */ + fun logPayloadSelectionChanged() } diff --git a/java/src/com/android/intentresolver/logging/EventLogImpl.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java index 39d23865..8e9543bc 100644 --- a/java/src/com/android/intentresolver/logging/EventLogImpl.java +++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java @@ -273,6 +273,11 @@ public class EventLogImpl implements EventLog { log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, mInstanceId); } + @Override + public void logPayloadSelectionChanged() { + log(SharesheetStandardEvent.SHARESHEET_PAYLOAD_TOGGLED, mInstanceId); + } + /** * Logs a UiEventReported event for a given share activity * @param event @@ -402,6 +407,9 @@ public class EventLogImpl implements EventLog { case ContentPreviewType.CONTENT_PREVIEW_FILE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE; case ContentPreviewType.CONTENT_PREVIEW_TEXT: + case ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION: + return FrameworkStatsLog + .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TOGGLEABLE_MEDIA; default: return FrameworkStatsLog .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN; diff --git a/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java index 8aee0da1..677b6366 100644 --- a/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java @@ -16,6 +16,8 @@ package com.android.intentresolver.profiles; +import static com.android.intentresolver.Flags.keyboardNavigationFix; + import android.content.Context; import android.os.UserHandle; import android.view.LayoutInflater; @@ -112,10 +114,22 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< } } + /** + * Set enabled status for all targets in all profiles. + */ + public void setTargetsEnabled(boolean isEnabled) { + for (int i = 0, size = getItemCount(); i < size; i++) { + getPageAdapterForIndex(i).getListAdapter().setTargetsEnabled(isEnabled); + } + } + private static ViewGroup makeProfileView(Context context) { LayoutInflater inflater = LayoutInflater.from(context); ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false); + if (!keyboardNavigationFix()) { + rootView.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); + } RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); recyclerView.setAccessibilityDelegateCompat( new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); diff --git a/java/src/com/android/intentresolver/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/shared/model/ActivityModel.kt index 4bcdd69b..c5efdeba 100644 --- a/java/src/com/android/intentresolver/ui/model/ActivityModel.kt +++ b/java/src/com/android/intentresolver/shared/model/ActivityModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 The Android Open Source Project + * 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.intentresolver.ui.model +package com.android.intentresolver.shared.model import android.app.Activity import android.content.Intent @@ -34,7 +34,7 @@ data class ActivityModel( /** The package of the sending app */ val launchedFromPackage: String, /** The referrer as supplied to the activity. */ - val referrer: Uri? + val referrer: Uri?, ) : Parcelable { constructor( source: Parcel @@ -42,7 +42,7 @@ data class ActivityModel( intent = source.requireParcelable(), launchedFromUid = source.readInt(), launchedFromPackage = requireNotNull(source.readString()), - referrer = source.readParcelable() + referrer = source.readParcelable(), ) /** A package name from referrer, if it is an android-app URI */ @@ -58,13 +58,12 @@ data class ActivityModel( } 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) } @@ -74,7 +73,7 @@ data class ActivityModel( activity.intent, activity.launchedFromUid, Objects.requireNonNull<String>(activity.launchedFromPackage), - activity.referrer + activity.referrer, ) } } diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index 08230d90..828d8561 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -35,16 +35,23 @@ import androidx.annotation.MainThread import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread +import com.android.intentresolver.Flags.fixShortcutLoaderJobLeak +import com.android.intentresolver.Flags.fixShortcutsFlashing import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.measurements.Tracer import com.android.intentresolver.measurements.runTracing import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicReference import java.util.function.Consumer import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter @@ -65,29 +72,35 @@ open class ShortcutLoader @VisibleForTesting constructor( private val context: Context, - private val scope: CoroutineScope, + parentScope: CoroutineScope, private val appPredictor: AppPredictorProxy?, private val userHandle: UserHandle, private val isPersonalProfile: Boolean, private val targetIntentFilter: IntentFilter?, private val dispatcher: CoroutineDispatcher, - private val callback: Consumer<Result> + private val callback: Consumer<Result>, ) { + private val scope = + if (fixShortcutLoaderJobLeak()) parentScope.createChildScope() else parentScope private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager + private val appPredictorWatchdog = AtomicReference<Job?>(null) private val appPredictorCallback = ScopedAppTargetListCallback(scope) { onAppPredictorCallback(it) }.toAppPredictorCallback() private val appTargetSource = MutableSharedFlow<Array<DisplayResolveInfo>?>( replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST + onBufferOverflow = BufferOverflow.DROP_OLDEST, ) private val shortcutSource = MutableSharedFlow<ShortcutData?>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val isDestroyed get() = !scope.isActive + private val id + get() = System.identityHashCode(this).toString(Character.MAX_RADIX) + @MainThread constructor( context: Context, @@ -95,7 +108,7 @@ constructor( appPredictor: AppPredictor?, userHandle: UserHandle, targetIntentFilter: IntentFilter?, - callback: Consumer<Result> + callback: Consumer<Result>, ) : this( context, scope, @@ -104,7 +117,7 @@ constructor( userHandle == UserHandle.of(ActivityManager.getCurrentUser()), targetIntentFilter, Dispatchers.IO, - callback + callback, ) init { @@ -121,7 +134,7 @@ constructor( appTargets, shortcutData.shortcuts, shortcutData.isFromAppPredictor, - shortcutData.appPredictorTargets + shortcutData.appPredictorTargets, ) } } @@ -132,7 +145,7 @@ constructor( } .invokeOnCompletion { runCatching { appPredictor?.unregisterPredictionUpdates(appPredictorCallback) } - Log.d(TAG, "destroyed, user: $userHandle") + Log.d(TAG, "[$id] destroyed, user: $userHandle") } reset() } @@ -140,7 +153,7 @@ constructor( /** Clear application targets (see [updateAppTargets] and initiate shortcuts loading. */ @OpenForTesting open fun reset() { - Log.d(TAG, "reset shortcut loader for user $userHandle") + Log.d(TAG, "[$id] reset shortcut loader for user $userHandle") appTargetSource.tryEmit(null) shortcutSource.tryEmit(null) scope.launch(dispatcher) { loadShortcuts() } @@ -155,14 +168,21 @@ constructor( appTargetSource.tryEmit(appTargets) } + @OpenForTesting + open fun destroy() { + if (fixShortcutLoaderJobLeak()) { + scope.cancel() + } + } + @WorkerThread private fun loadShortcuts() { // no need to query direct share for work profile when its locked or disabled if (!shouldQueryDirectShareTargets()) { - Log.d(TAG, "skip shortcuts loading for user $userHandle") + Log.d(TAG, "[$id] skip shortcuts loading for user $userHandle") return } - Log.d(TAG, "querying direct share targets for user $userHandle") + Log.d(TAG, "[$id] querying direct share targets for user $userHandle") queryDirectShareTargets(false) } @@ -170,9 +190,30 @@ constructor( private fun queryDirectShareTargets(skipAppPredictionService: Boolean) { if (!skipAppPredictionService && appPredictor != null) { try { - Log.d(TAG, "query AppPredictor for user $userHandle") + Log.d(TAG, "[$id] query AppPredictor for user $userHandle") + + val watchdogJob = + if (fixShortcutsFlashing()) { + scope + .launch(start = CoroutineStart.LAZY) { + delay(APP_PREDICTOR_RESPONSE_TIMEOUT_MS) + Log.w(TAG, "AppPredictor response timeout for user: $userHandle") + appPredictorCallback.onTargetsAvailable(emptyList()) + } + .also { job -> + appPredictorWatchdog.getAndSet(job)?.cancel() + job.invokeOnCompletion { + appPredictorWatchdog.compareAndSet(job, null) + } + } + } else { + null + } + Tracer.beginAppPredictorQueryTrace(userHandle) appPredictor.requestPredictionUpdate() + + watchdogJob?.start() return } catch (e: Throwable) { endAppPredictorQueryTrace(userHandle) @@ -180,25 +221,25 @@ constructor( if (isDestroyed) { return } - Log.e(TAG, "Failed to query AppPredictor for user $userHandle", e) + Log.e(TAG, "[$id] failed to query AppPredictor for user $userHandle", e) } } // Default to just querying ShortcutManager if AppPredictor not present. if (targetIntentFilter == null) { - Log.d(TAG, "skip querying ShortcutManager for $userHandle") + Log.d(TAG, "[$id] skip querying ShortcutManager for $userHandle") sendShareShortcutInfoList( emptyList(), isFromAppPredictor = false, - appPredictorTargets = null + appPredictorTargets = null, ) return } - Log.d(TAG, "query ShortcutManager for user $userHandle") + Log.d(TAG, "[$id] query ShortcutManager for user $userHandle") val shortcuts = runTracing("shortcut-mngr-${userHandle.identifier}") { queryShortcutManager(targetIntentFilter) } - Log.d(TAG, "receive shortcuts from ShortcutManager for user $userHandle") + Log.d(TAG, "[$id] receive shortcuts from ShortcutManager for user $userHandle") sendShareShortcutInfoList(shortcuts, false, null) } @@ -210,14 +251,14 @@ constructor( val pm = context.createContextAsUser(userHandle, 0 /* flags */).packageManager return sm?.getShareTargets(targetIntentFilter)?.filter { pm.isPackageEnabled(it.targetComponent.packageName) - } - ?: emptyList() + } ?: emptyList() } @WorkerThread private fun onAppPredictorCallback(appPredictorTargets: List<AppTarget>) { + appPredictorWatchdog.get()?.cancel() endAppPredictorQueryTrace(userHandle) - Log.d(TAG, "receive app targets from AppPredictor") + Log.d(TAG, "[$id] receive app targets from AppPredictor") if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { // APS may be disabled, so try querying targets ourselves. queryDirectShareTargets(true) @@ -247,7 +288,7 @@ constructor( private fun sendShareShortcutInfoList( shortcuts: List<ShareShortcutInfo>, isFromAppPredictor: Boolean, - appPredictorTargets: List<AppTarget>? + appPredictorTargets: List<AppTarget>?, ) { shortcutSource.tryEmit(ShortcutData(shortcuts, isFromAppPredictor, appPredictorTargets)) } @@ -256,7 +297,7 @@ constructor( appTargets: Array<DisplayResolveInfo>, shortcuts: List<ShareShortcutInfo>, isFromAppPredictor: Boolean, - appPredictorTargets: List<AppTarget>? + appPredictorTargets: List<AppTarget>?, ): Result { if (appPredictorTargets != null && appPredictorTargets.size != shortcuts.size) { throw RuntimeException( @@ -283,7 +324,7 @@ constructor( shortcuts, appPredictorTargets, directShareAppTargetCache, - directShareShortcutInfoCache + directShareShortcutInfoCache, ) val resultRecord = ShortcutResultInfo(displayResolveInfo, chooserTargets) resultRecords.add(resultRecord) @@ -293,7 +334,7 @@ constructor( appTargets, resultRecords.toTypedArray(), directShareAppTargetCache, - directShareShortcutInfoCache + directShareShortcutInfoCache, ) } @@ -313,7 +354,7 @@ constructor( private class ShortcutData( val shortcuts: List<ShareShortcutInfo>, val isFromAppPredictor: Boolean, - val appPredictorTargets: List<AppTarget>? + val appPredictorTargets: List<AppTarget>?, ) /** Resolved shortcuts with corresponding app targets. */ @@ -327,18 +368,23 @@ constructor( /** Shortcuts grouped by app target. */ val shortcutsByApp: Array<ShortcutResultInfo>, val directShareAppTargetCache: Map<ChooserTarget, AppTarget>, - val directShareShortcutInfoCache: Map<ChooserTarget, ShortcutInfo> + val directShareShortcutInfoCache: Map<ChooserTarget, ShortcutInfo>, ) + private fun endAppPredictorQueryTrace(userHandle: UserHandle) { + val duration = Tracer.endAppPredictorQueryTrace(userHandle) + Log.d(TAG, "[$id] AppPredictor query duration for user $userHandle: $duration ms") + } + /** Shortcuts grouped by app. */ class ShortcutResultInfo( val appTarget: DisplayResolveInfo, - val shortcuts: List<ChooserTarget?> + val shortcuts: List<ChooserTarget?>, ) private class ShortcutsAppTargetsPair( val shortcuts: List<ShareShortcutInfo>, - val appTargets: List<AppTarget>? + val appTargets: List<AppTarget>?, ) /** A wrapper around AppPredictor to facilitate unit-testing. */ @@ -347,7 +393,7 @@ constructor( /** [AppPredictor.registerPredictionUpdates] */ open fun registerPredictionUpdates( callbackExecutor: Executor, - callback: AppPredictor.Callback + callback: AppPredictor.Callback, ) = mAppPredictor.registerPredictionUpdates(callbackExecutor, callback) /** [AppPredictor.unregisterPredictionUpdates] */ @@ -359,6 +405,7 @@ constructor( } companion object { + @VisibleForTesting const val APP_PREDICTOR_RESPONSE_TIMEOUT_MS = 2_000L private const val TAG = "ShortcutLoader" private fun PackageManager.isPackageEnabled(packageName: String): Boolean { @@ -371,16 +418,19 @@ constructor( packageName, PackageManager.ApplicationInfoFlags.of( PackageManager.GET_META_DATA.toLong() - ) + ), ) appInfo.enabled && (appInfo.flags and ApplicationInfo.FLAG_SUSPENDED) == 0 } .getOrDefault(false) } - private fun endAppPredictorQueryTrace(userHandle: UserHandle) { - val duration = Tracer.endAppPredictorQueryTrace(userHandle) - Log.d(TAG, "AppPredictor query duration for user $userHandle: $duration ms") - } + /** + * Creates a new coroutine scope and makes its job a child of the given, `this`, coroutine + * scope's job. This ensures that the new scope will be canceled when the parent scope is + * canceled (but not vice versa). + */ + private fun CoroutineScope.createChildScope() = + CoroutineScope(coroutineContext + Job(parent = coroutineContext[Job])) } } diff --git a/java/src/com/android/intentresolver/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/ui/ShareResultSender.kt index 7be2076e..2684b817 100644 --- a/java/src/com/android/intentresolver/ui/ShareResultSender.kt +++ b/java/src/com/android/intentresolver/ui/ShareResultSender.kt @@ -30,7 +30,6 @@ 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.ui.model.ShareAction import dagger.assisted.Assisted @@ -47,7 +46,7 @@ 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) + fun onComponentSelected(component: ComponentName, directShare: Boolean, crossProfile: Boolean) /** Reports user invocation of a built-in system action. See [ShareAction]. */ fun onActionSelected(action: ShareAction) @@ -64,7 +63,6 @@ fun interface IntentSenderDispatcher { } class ShareResultSenderImpl( - private val flags: ChooserServiceFlags, @Main private val scope: CoroutineScope, @Background val backgroundDispatcher: CoroutineDispatcher, private val callerUid: Int, @@ -74,13 +72,11 @@ class ShareResultSenderImpl( @AssistedInject constructor( @ActivityContext context: Context, - flags: ChooserServiceFlags, @Main scope: CoroutineScope, @Background backgroundDispatcher: CoroutineDispatcher, @Assisted callerUid: Int, @Assisted chosenComponentSender: IntentSender, ) : this( - flags, scope, backgroundDispatcher, callerUid, @@ -88,18 +84,22 @@ class ShareResultSenderImpl( IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) } ) - override fun onComponentSelected(component: ComponentName, directShare: Boolean) { - Log.i(TAG, "onComponentSelected: $component directShare=$directShare") + override fun onComponentSelected( + component: ComponentName, + directShare: Boolean, + crossProfile: Boolean + ) { + Log.i(TAG, "onComponentSelected: $component directShare=$directShare cross=$crossProfile") scope.launch { - val intent = createChosenComponentIntent(component, directShare) - intentDispatcher.dispatchIntent(resultSender, intent) + val intent = createChosenComponentIntent(component, directShare, crossProfile) + intent?.let { intentDispatcher.dispatchIntent(resultSender, it) } } } override fun onActionSelected(action: ShareAction) { Log.i(TAG, "onActionSelected: $action") scope.launch { - if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + if (chooserResultSupported(callerUid)) { @ResultType val chosenAction = shareActionToChooserResult(action) val intent: Intent = createSelectedActionIntent(chosenAction) intentDispatcher.dispatchIntent(resultSender, intent) @@ -112,20 +112,38 @@ class ShareResultSenderImpl( 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) - ) + crossProfile: Boolean, + ): Intent? { + if (chooserResultSupported(callerUid)) { + if (crossProfile) { + Log.i(TAG, "Redacting package from cross-profile ${Intent.EXTRA_CHOOSER_RESULT}") + return Intent() + .putExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult(CHOOSER_RESULT_UNKNOWN, null, direct) + ) + } else { + // Add extra with component name for backwards compatibility. + val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) + + // Add ChooserResult value for Android V+ + intent.putExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct) + ) + return intent + } } else { - Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}") + if (crossProfile) { + // We can only send cross-profile results in the new ChooserResult format. + Log.i(TAG, "Omitting selection callback for cross-profile target") + return null + } else { + val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) + Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}") + return intent + } } - return intent } @ResultType diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt index a9b6de7e..13cadf37 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -18,7 +18,10 @@ package com.android.intentresolver.ui.viewmodel import android.content.ComponentName import android.content.Intent import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI +import android.content.Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION 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 @@ -46,7 +49,7 @@ import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.ext.hasSendAction import com.android.intentresolver.ext.ifMatch import com.android.intentresolver.inject.ChooserServiceFlags -import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.util.hasValidIcon import com.android.intentresolver.validation.Validation import com.android.intentresolver.validation.ValidationResult @@ -66,7 +69,7 @@ internal fun Intent.maybeAddSendActionFlags() = fun readChooserRequest( model: ActivityModel, - flags: ChooserServiceFlags + flags: ChooserServiceFlags, ): ValidationResult<ChooserRequest> { val extras = model.intent.extras ?: Bundle() @Suppress("DEPRECATION") @@ -84,7 +87,7 @@ fun readChooserRequest( 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." + "property of the wrapped EXTRA_INTENT.", ) null to R.string.chooseActivity } else { @@ -95,8 +98,7 @@ fun readChooserRequest( val initialIntents = optional(array<Intent>(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map { it.maybeAddSendActionFlags() - } - ?: emptyList() + } ?: emptyList() val chosenComponentSender = optional(value<IntentSender>(EXTRA_CHOOSER_RESULT_INTENT_SENDER)) @@ -115,7 +117,8 @@ fun readChooserRequest( val retainInOnStop = optional(value<Boolean>(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false - val sharedText = optional(value<CharSequence>(EXTRA_TEXT)) + val sharedTextTitle = targetIntent.getCharSequenceExtra(EXTRA_TITLE) + val sharedText = targetIntent.getCharSequenceExtra(EXTRA_TEXT) val chooserActions = readChooserActions() ?: emptyList() @@ -124,29 +127,20 @@ fun readChooserRequest( 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 + additionalContentUri = optional(value<Uri>(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) + focusedItemPos = optional(value<Int>(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 + when (optional(value<Int>(EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { + Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM + else -> ContentTypeHint.NONE } - val metadataText = - if (flags.enableSharesheetMetadataExtra()) { - optional(value<CharSequence>(EXTRA_METADATA_TEXT)) - } else { - null - } + val metadataText = optional(value<CharSequence>(EXTRA_METADATA_TEXT)) ChooserRequest( targetIntent = targetIntent, @@ -171,6 +165,7 @@ fun readChooserRequest( chosenComponentSender = chosenComponentSender, refinementIntentSender = refinementIntentSender, sharedText = sharedText, + sharedTextTitle = sharedTextTitle, shareTargetFilter = targetIntent.toShareTargetFilter(), additionalContentUri = additionalContentUri, focusedItemPosition = focusedItemPos, diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt index c9cae3db..fe7e9109 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -15,19 +15,21 @@ */ package com.android.intentresolver.ui.viewmodel +import android.content.ContentInterface import android.util.Log -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.PreviewDataProvider import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ProcessTargetIntentUpdatesInteractor import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.data.repository.ActivityModelRepository import com.android.intentresolver.data.repository.ChooserRequestRepository import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ChooserServiceFlags -import com.android.intentresolver.ui.model.ActivityModel -import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.Valid import com.android.intentresolver.validation.ValidationResult @@ -38,6 +40,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.plus private const val TAG = "ChooserViewModel" @@ -45,7 +48,7 @@ private const val TAG = "ChooserViewModel" class ChooserViewModel @Inject constructor( - args: SavedStateHandle, + activityModelRepository: ActivityModelRepository, private val shareouselViewModelProvider: Lazy<ShareouselViewModel>, private val processUpdatesInteractor: Lazy<ProcessTargetIntentUpdatesInteractor>, private val fetchPreviewsInteractor: Lazy<FetchPreviewsInteractor>, @@ -58,13 +61,12 @@ constructor( */ val initialRequest: ValidationResult<ChooserRequest>, private val chooserRequestRepository: Lazy<ChooserRequestRepository>, + private val contentResolver: ContentInterface, + val imageLoader: ImageLoader, ) : ViewModel() { /** Parcelable-only references provided from the creating Activity */ - val activityModel: ActivityModel = - requireNotNull(args[ACTIVITY_MODEL_KEY]) { - "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" - } + val activityModel: ActivityModel = activityModelRepository.value val shareouselViewModel: ShareouselViewModel by lazy { // TODO: consolidate this logic, this would require a consolidated preview view model but @@ -86,6 +88,16 @@ constructor( val request: StateFlow<ChooserRequest> get() = chooserRequestRepository.get().chooserRequest.asStateFlow() + val previewDataProvider by lazy { + val chooserRequest = (initialRequest as Valid<ChooserRequest>).value + PreviewDataProvider( + viewModelScope + bgDispatcher, + chooserRequest.targetIntent, + chooserRequest.additionalContentUri, + contentResolver, + ) + } + init { if (initialRequest is Invalid) { Log.w(TAG, "initialRequest is Invalid, initialization failed") diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt index 856d9fdd..884be635 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt @@ -20,8 +20,8 @@ import android.os.Bundle import android.os.UserHandle import com.android.intentresolver.ResolverActivity.PROFILE_PERSONAL import com.android.intentresolver.ResolverActivity.PROFILE_WORK +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.shared.model.Profile -import com.android.intentresolver.ui.model.ActivityModel import com.android.intentresolver.ui.model.ResolverRequest import com.android.intentresolver.validation.Validation import com.android.intentresolver.validation.ValidationResult diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt index a3dc58a6..3511637b 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt @@ -17,10 +17,9 @@ package com.android.intentresolver.ui.viewmodel import android.util.Log -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import com.android.intentresolver.ui.model.ActivityModel -import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.data.repository.ActivityModelRepository +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.ui.model.ResolverRequest import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.Valid @@ -33,13 +32,11 @@ import kotlinx.coroutines.flow.asStateFlow private const val TAG = "ResolverViewModel" @HiltViewModel -class ResolverViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() { +class ResolverViewModel @Inject constructor(activityModelrepo: ActivityModelRepository) : + ViewModel() { /** Parcelable-only references provided from the creating Activity */ - val activityModel: ActivityModel = - requireNotNull(args[ACTIVITY_MODEL_KEY]) { - "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" - } + val activityModel: ActivityModel = activityModelrepo.value /** * Provided only for the express purpose of early exit in the event of an invalid request. diff --git a/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt b/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt new file mode 100644 index 00000000..3e2d8e2a --- /dev/null +++ b/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt @@ -0,0 +1,46 @@ +/* + * 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("SuspendedMatrixColorFilter") + +package com.android.intentresolver.util.graphics + +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter + +val suspendedColorMatrix by lazy { + val grayValue = 127f + val scale = 0.5f // half bright + + val tempBrightnessMatrix = + ColorMatrix().apply { + array.let { m -> + m[0] = scale + m[6] = scale + m[12] = scale + m[4] = grayValue + m[9] = grayValue + m[14] = grayValue + } + } + + val matrix = + ColorMatrix().apply { + setSaturation(0.0f) + preConcat(tempBrightnessMatrix) + } + ColorMatrixColorFilter(matrix) +} diff --git a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt index e86de888..a9577cf5 100644 --- a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt +++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt @@ -25,7 +25,7 @@ import androidx.core.view.marginBottom import androidx.core.view.marginLeft import androidx.core.view.marginRight import androidx.core.view.marginTop -import androidx.core.widget.NestedScrollView +import com.android.intentresolver.Flags.keyboardNavigationFix /** * A narrowly tailored [NestedScrollView] to be used inside [ResolverDrawerLayout] and help to @@ -35,13 +35,17 @@ import androidx.core.widget.NestedScrollView */ class ChooserNestedScrollView : NestedScrollView { constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor( context: Context, attrs: AttributeSet?, - defStyleAttr: Int + defStyleAttr: Int, ) : super(context, attrs, defStyleAttr) + var requestChildFocusPredicate: (View?, View?) -> Boolean = DefaultChildFocusPredicate + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val content = getChildAt(0) as? LinearLayout ?: error("Exactly one child, LinerLayout, is expected") @@ -55,13 +59,13 @@ class ChooserNestedScrollView : NestedScrollView { getChildMeasureSpec( widthMeasureSpec, paddingLeft + content.marginLeft + content.marginRight + paddingRight, - lp.width + lp.width, ) val contentHeightSpec = getChildMeasureSpec( heightMeasureSpec, paddingTop + content.marginTop + content.marginBottom + paddingBottom, - lp.height + lp.height, ) content.measure(contentWidthSpec, contentHeightSpec) @@ -76,7 +80,7 @@ class ChooserNestedScrollView : NestedScrollView { content.measure( contentWidthSpec, - MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)) + MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)), ) } setMeasuredDimension( @@ -87,8 +91,8 @@ class ChooserNestedScrollView : NestedScrollView { content.marginTop + content.measuredHeight + content.marginBottom + - paddingBottom - ) + paddingBottom, + ), ) } @@ -103,4 +107,18 @@ class ChooserNestedScrollView : NestedScrollView { consumed[1] += scrollY - preScrollY } } + + override fun onRequestChildFocus(child: View?, focused: View?) { + if (keyboardNavigationFix()) { + if (requestChildFocusPredicate(child, focused)) { + super.onRequestChildFocus(child, focused) + } + } else { + super.onRequestChildFocus(child, focused) + } + } + + companion object { + val DefaultChildFocusPredicate: (View?, View?) -> Boolean = { _, _ -> true } + } } diff --git a/java/src/com/android/intentresolver/widget/NestedScrollView.java b/java/src/com/android/intentresolver/widget/NestedScrollView.java new file mode 100644 index 00000000..36fc7da6 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/NestedScrollView.java @@ -0,0 +1,2611 @@ +/* + * 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.widget; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.hardware.SensorManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.Log; +import android.util.TypedValue; +import android.view.FocusFinder; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.view.animation.AnimationUtils; +import android.widget.EdgeEffect; +import android.widget.FrameLayout; +import android.widget.OverScroller; +import android.widget.ScrollView; + +import androidx.annotation.DoNotInline; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.annotation.VisibleForTesting; +import androidx.core.R; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.DifferentialMotionFlingController; +import androidx.core.view.DifferentialMotionFlingTarget; +import androidx.core.view.MotionEventCompat; +import androidx.core.view.NestedScrollingChild3; +import androidx.core.view.NestedScrollingChildHelper; +import androidx.core.view.NestedScrollingParent3; +import androidx.core.view.NestedScrollingParentHelper; +import androidx.core.view.ScrollingView; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityRecordCompat; +import androidx.core.widget.EdgeEffectCompat; + +import java.util.List; + +/** + * A copy of the {@link androidx.core.widget.NestedScrollView} (from + * prebuilts/sdk/current/androidx/m2repository/androidx/core/core/1.13.0-beta01/core-1.13.0-beta01-sources.jar) + * without any functional changes with a pure refactoring of {@link #requestChildFocus(View, View)}: + * the method's body is extracted into the new protected method, + * {@link #onRequestChildFocus(View, View)}. + * <p> + * For the exact change see NestedScrollView.java.patch file. + * </p> + */ +public class NestedScrollView extends FrameLayout implements NestedScrollingParent3, + NestedScrollingChild3, ScrollingView { + static final int ANIMATED_SCROLL_GAP = 250; + + static final float MAX_SCROLL_FACTOR = 0.5f; + + private static final String TAG = "NestedScrollView"; + private static final int DEFAULT_SMOOTH_SCROLL_DURATION = 250; + + /** + * The following are copied from OverScroller to determine how far a fling will go. + */ + private static final float SCROLL_FRICTION = 0.015f; + private static final float INFLEXION = 0.35f; // Tension lines cross at (INFLEXION, 1) + private static final float DECELERATION_RATE = (float) (Math.log(0.78) / Math.log(0.9)); + private final float mPhysicalCoeff; + + /** + * When flinging the stretch towards scrolling content, it should destretch quicker than the + * fling would normally do. The visual effect of flinging the stretch looks strange as little + * appears to happen at first and then when the stretch disappears, the content starts + * scrolling quickly. + */ + private static final float FLING_DESTRETCH_FACTOR = 4f; + + /** + * Interface definition for a callback to be invoked when the scroll + * X or Y positions of a view change. + * + * <p>This version of the interface works on all versions of Android, back to API v4.</p> + * + * @see #setOnScrollChangeListener(OnScrollChangeListener) + */ + public interface OnScrollChangeListener { + /** + * Called when the scroll position of a view changes. + * @param v The view whose scroll position has changed. + * @param scrollX Current horizontal scroll origin. + * @param scrollY Current vertical scroll origin. + * @param oldScrollX Previous horizontal scroll origin. + * @param oldScrollY Previous vertical scroll origin. + */ + void onScrollChange(@NonNull NestedScrollView v, int scrollX, int scrollY, + int oldScrollX, int oldScrollY); + } + + private long mLastScroll; + + private final Rect mTempRect = new Rect(); + private OverScroller mScroller; + + @RestrictTo(LIBRARY) + @VisibleForTesting + @NonNull + public EdgeEffect mEdgeGlowTop; + + @RestrictTo(LIBRARY) + @VisibleForTesting + @NonNull + public EdgeEffect mEdgeGlowBottom; + + /** + * Position of the last motion event; only used with touch related events (usually to assist + * in movement changes in a drag gesture). + */ + private int mLastMotionY; + + /** + * True when the layout has changed but the traversal has not come through yet. + * Ideally the view hierarchy would keep track of this for us. + */ + private boolean mIsLayoutDirty = true; + private boolean mIsLaidOut = false; + + /** + * The child to give focus to in the event that a child has requested focus while the + * layout is dirty. This prevents the scroll from being wrong if the child has not been + * laid out before requesting focus. + */ + private View mChildToScrollTo = null; + + /** + * True if the user is currently dragging this ScrollView around. This is + * not the same as 'is being flinged', which can be checked by + * mScroller.isFinished() (flinging begins when the user lifts their finger). + */ + private boolean mIsBeingDragged = false; + + /** + * Determines speed during touch scrolling + */ + private VelocityTracker mVelocityTracker; + + /** + * When set to true, the scroll view measure its child to make it fill the currently + * visible area. + */ + private boolean mFillViewport; + + /** + * Whether arrow scrolling is animated. + */ + private boolean mSmoothScrollingEnabled = true; + + private int mTouchSlop; + private int mMinimumVelocity; + private int mMaximumVelocity; + + /** + * ID of the active pointer. This is used to retain consistency during + * drags/flings if multiple pointers are used. + */ + private int mActivePointerId = INVALID_POINTER; + + /** + * Used during scrolling to retrieve the new offset within the window. Saves memory by saving + * x, y changes to this array (0 position = x, 1 position = y) vs. reallocating an x and y + * every time. + */ + private final int[] mScrollOffset = new int[2]; + + /* + * Used during scrolling to retrieve the new consumed offset within the window. + * Uses same memory saving strategy as mScrollOffset. + */ + private final int[] mScrollConsumed = new int[2]; + + // Used to track the position of the touch only events relative to the container. + private int mNestedYOffset; + + private int mLastScrollerY; + + /** + * Sentinel value for no current active pointer. + * Used by {@link #mActivePointerId}. + */ + private static final int INVALID_POINTER = -1; + + private SavedState mSavedState; + + private static final AccessibilityDelegate ACCESSIBILITY_DELEGATE = new AccessibilityDelegate(); + + private static final int[] SCROLLVIEW_STYLEABLE = new int[] { + android.R.attr.fillViewport + }; + + private final NestedScrollingParentHelper mParentHelper; + private final NestedScrollingChildHelper mChildHelper; + + private float mVerticalScrollFactor; + + private OnScrollChangeListener mOnScrollChangeListener; + + @VisibleForTesting + final DifferentialMotionFlingTargetImpl mDifferentialMotionFlingTarget = + new DifferentialMotionFlingTargetImpl(); + + @VisibleForTesting + DifferentialMotionFlingController mDifferentialMotionFlingController = + new DifferentialMotionFlingController(getContext(), mDifferentialMotionFlingTarget); + + public NestedScrollView(@NonNull Context context) { + this(context, null); + } + + public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, R.attr.nestedScrollViewStyle); + } + + public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs, + int defStyleAttr) { + super(context, attrs, defStyleAttr); + mEdgeGlowTop = EdgeEffectCompat.create(context, attrs); + mEdgeGlowBottom = EdgeEffectCompat.create(context, attrs); + + final float ppi = context.getResources().getDisplayMetrics().density * 160.0f; + mPhysicalCoeff = SensorManager.GRAVITY_EARTH // g (m/s^2) + * 39.37f // inch/meter + * ppi + * 0.84f; // look and feel tuning + + initScrollView(); + + final TypedArray a = context.obtainStyledAttributes( + attrs, SCROLLVIEW_STYLEABLE, defStyleAttr, 0); + + setFillViewport(a.getBoolean(0, false)); + + a.recycle(); + + mParentHelper = new NestedScrollingParentHelper(this); + mChildHelper = new NestedScrollingChildHelper(this); + + // ...because why else would you be using this widget? + setNestedScrollingEnabled(true); + + ViewCompat.setAccessibilityDelegate(this, ACCESSIBILITY_DELEGATE); + } + + // NestedScrollingChild3 + + @Override + public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, + int dyUnconsumed, @Nullable int[] offsetInWindow, int type, @NonNull int[] consumed) { + mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, + offsetInWindow, type, consumed); + } + + // NestedScrollingChild2 + + @Override + public boolean startNestedScroll(int axes, int type) { + return mChildHelper.startNestedScroll(axes, type); + } + + @Override + public void stopNestedScroll(int type) { + mChildHelper.stopNestedScroll(type); + } + + @Override + public boolean hasNestedScrollingParent(int type) { + return mChildHelper.hasNestedScrollingParent(type); + } + + @Override + public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, + int dyUnconsumed, @Nullable int[] offsetInWindow, int type) { + return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, + offsetInWindow, type); + } + + @Override + public boolean dispatchNestedPreScroll( + int dx, + int dy, + @Nullable int[] consumed, + @Nullable int[] offsetInWindow, + int type + ) { + return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type); + } + + // NestedScrollingChild + + @Override + public void setNestedScrollingEnabled(boolean enabled) { + mChildHelper.setNestedScrollingEnabled(enabled); + } + + @Override + public boolean isNestedScrollingEnabled() { + return mChildHelper.isNestedScrollingEnabled(); + } + + @Override + public boolean startNestedScroll(int axes) { + return startNestedScroll(axes, ViewCompat.TYPE_TOUCH); + } + + @Override + public void stopNestedScroll() { + stopNestedScroll(ViewCompat.TYPE_TOUCH); + } + + @Override + public boolean hasNestedScrollingParent() { + return hasNestedScrollingParent(ViewCompat.TYPE_TOUCH); + } + + @Override + public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, + int dyUnconsumed, @Nullable int[] offsetInWindow) { + return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, + offsetInWindow); + } + + @Override + public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, + @Nullable int[] offsetInWindow) { + return dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, ViewCompat.TYPE_TOUCH); + } + + @Override + public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { + return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); + } + + @Override + public boolean dispatchNestedPreFling(float velocityX, float velocityY) { + return mChildHelper.dispatchNestedPreFling(velocityX, velocityY); + } + + // NestedScrollingParent3 + + @Override + public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) { + onNestedScrollInternal(dyUnconsumed, type, consumed); + } + + private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) { + final int oldScrollY = getScrollY(); + scrollBy(0, dyUnconsumed); + final int myConsumed = getScrollY() - oldScrollY; + + if (consumed != null) { + consumed[1] += myConsumed; + } + final int myUnconsumed = dyUnconsumed - myConsumed; + + mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed); + } + + // NestedScrollingParent2 + + @Override + public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, + int type) { + return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; + } + + @Override + public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, + int type) { + mParentHelper.onNestedScrollAccepted(child, target, axes, type); + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type); + } + + @Override + public void onStopNestedScroll(@NonNull View target, int type) { + mParentHelper.onStopNestedScroll(target, type); + stopNestedScroll(type); + } + + @Override + public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed, int type) { + onNestedScrollInternal(dyUnconsumed, type, null); + } + + @Override + public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, + int type) { + dispatchNestedPreScroll(dx, dy, consumed, null, type); + } + + // NestedScrollingParent + + @Override + public boolean onStartNestedScroll( + @NonNull View child, @NonNull View target, int axes) { + return onStartNestedScroll(child, target, axes, ViewCompat.TYPE_TOUCH); + } + + @Override + public void onNestedScrollAccepted( + @NonNull View child, @NonNull View target, int axes) { + onNestedScrollAccepted(child, target, axes, ViewCompat.TYPE_TOUCH); + } + + @Override + public void onStopNestedScroll(@NonNull View target) { + onStopNestedScroll(target, ViewCompat.TYPE_TOUCH); + } + + @Override + public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed) { + onNestedScrollInternal(dyUnconsumed, ViewCompat.TYPE_TOUCH, null); + } + + @Override + public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) { + onNestedPreScroll(target, dx, dy, consumed, ViewCompat.TYPE_TOUCH); + } + + @Override + public boolean onNestedFling( + @NonNull View target, float velocityX, float velocityY, boolean consumed) { + if (!consumed) { + dispatchNestedFling(0, velocityY, true); + fling((int) velocityY); + return true; + } + return false; + } + + @Override + public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) { + return dispatchNestedPreFling(velocityX, velocityY); + } + + @Override + public int getNestedScrollAxes() { + return mParentHelper.getNestedScrollAxes(); + } + + // ScrollView import + + @Override + public boolean shouldDelayChildPressedState() { + return true; + } + + @Override + protected float getTopFadingEdgeStrength() { + if (getChildCount() == 0) { + return 0.0f; + } + + final int length = getVerticalFadingEdgeLength(); + final int scrollY = getScrollY(); + if (scrollY < length) { + return scrollY / (float) length; + } + + return 1.0f; + } + + @Override + protected float getBottomFadingEdgeStrength() { + if (getChildCount() == 0) { + return 0.0f; + } + + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + final int length = getVerticalFadingEdgeLength(); + final int bottomEdge = getHeight() - getPaddingBottom(); + final int span = child.getBottom() + lp.bottomMargin - getScrollY() - bottomEdge; + if (span < length) { + return span / (float) length; + } + + return 1.0f; + } + + /** + * @return The maximum amount this scroll view will scroll in response to + * an arrow event. + */ + public int getMaxScrollAmount() { + return (int) (MAX_SCROLL_FACTOR * getHeight()); + } + + private void initScrollView() { + mScroller = new OverScroller(getContext()); + setFocusable(true); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + setWillNotDraw(false); + final ViewConfiguration configuration = ViewConfiguration.get(getContext()); + mTouchSlop = configuration.getScaledTouchSlop(); + mMinimumVelocity = configuration.getScaledMinimumFlingVelocity(); + mMaximumVelocity = configuration.getScaledMaximumFlingVelocity(); + } + + @Override + public void addView(@NonNull View child) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child); + } + + @Override + public void addView(View child, int index) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child, index); + } + + @Override + public void addView(View child, ViewGroup.LayoutParams params) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child, params); + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child, index, params); + } + + /** + * Register a callback to be invoked when the scroll X or Y positions of + * this view change. + * <p>This version of the method works on all versions of Android, back to API v4.</p> + * + * @param l The listener to notify when the scroll X or Y position changes. + * @see View#getScrollX() + * @see View#getScrollY() + */ + public void setOnScrollChangeListener(@Nullable OnScrollChangeListener l) { + mOnScrollChangeListener = l; + } + + /** + * @return Returns true this ScrollView can be scrolled + */ + private boolean canScroll() { + if (getChildCount() > 0) { + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int childSize = child.getHeight() + lp.topMargin + lp.bottomMargin; + int parentSpace = getHeight() - getPaddingTop() - getPaddingBottom(); + return childSize > parentSpace; + } + return false; + } + + /** + * Indicates whether this ScrollView's content is stretched to fill the viewport. + * + * @return True if the content fills the viewport, false otherwise. + * + * @attr name android:fillViewport + */ + public boolean isFillViewport() { + return mFillViewport; + } + + /** + * Set whether this ScrollView should stretch its content height to fill the viewport or not. + * + * @param fillViewport True to stretch the content's height to the viewport's + * boundaries, false otherwise. + * + * @attr name android:fillViewport + */ + public void setFillViewport(boolean fillViewport) { + if (fillViewport != mFillViewport) { + mFillViewport = fillViewport; + requestLayout(); + } + } + + /** + * @return Whether arrow scrolling will animate its transition. + */ + public boolean isSmoothScrollingEnabled() { + return mSmoothScrollingEnabled; + } + + /** + * Set whether arrow scrolling will animate its transition. + * @param smoothScrollingEnabled whether arrow scrolling will animate its transition + */ + public void setSmoothScrollingEnabled(boolean smoothScrollingEnabled) { + mSmoothScrollingEnabled = smoothScrollingEnabled; + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + + if (mOnScrollChangeListener != null) { + mOnScrollChangeListener.onScrollChange(this, l, t, oldl, oldt); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (!mFillViewport) { + return; + } + + final int heightMode = MeasureSpec.getMode(heightMeasureSpec); + if (heightMode == MeasureSpec.UNSPECIFIED) { + return; + } + + if (getChildCount() > 0) { + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + + int childSize = child.getMeasuredHeight(); + int parentSpace = getMeasuredHeight() + - getPaddingTop() + - getPaddingBottom() + - lp.topMargin + - lp.bottomMargin; + + if (childSize < parentSpace) { + int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, + getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin, + lp.width); + int childHeightMeasureSpec = + MeasureSpec.makeMeasureSpec(parentSpace, MeasureSpec.EXACTLY); + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + } + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Let the focused view and/or our descendants get the key first + return super.dispatchKeyEvent(event) || executeKeyEvent(event); + } + + /** + * You can call this function yourself to have the scroll view perform + * scrolling from a key event, just as if the event had been dispatched to + * it by the view hierarchy. + * + * @param event The key event to execute. + * @return Return true if the event was handled, else false. + */ + public boolean executeKeyEvent(@NonNull KeyEvent event) { + mTempRect.setEmpty(); + + if (!canScroll()) { + if (isFocused() && event.getKeyCode() != KeyEvent.KEYCODE_BACK) { + View currentFocused = findFocus(); + if (currentFocused == this) currentFocused = null; + View nextFocused = FocusFinder.getInstance().findNextFocus(this, + currentFocused, View.FOCUS_DOWN); + return nextFocused != null + && nextFocused != this + && nextFocused.requestFocus(View.FOCUS_DOWN); + } + return false; + } + + boolean handled = false; + if (event.getAction() == KeyEvent.ACTION_DOWN) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_DPAD_UP: + if (event.isAltPressed()) { + handled = fullScroll(View.FOCUS_UP); + } else { + handled = arrowScroll(View.FOCUS_UP); + } + break; + case KeyEvent.KEYCODE_DPAD_DOWN: + if (event.isAltPressed()) { + handled = fullScroll(View.FOCUS_DOWN); + } else { + handled = arrowScroll(View.FOCUS_DOWN); + } + break; + case KeyEvent.KEYCODE_PAGE_UP: + handled = fullScroll(View.FOCUS_UP); + break; + case KeyEvent.KEYCODE_PAGE_DOWN: + handled = fullScroll(View.FOCUS_DOWN); + break; + case KeyEvent.KEYCODE_SPACE: + pageScroll(event.isShiftPressed() ? View.FOCUS_UP : View.FOCUS_DOWN); + break; + case KeyEvent.KEYCODE_MOVE_HOME: + pageScroll(View.FOCUS_UP); + break; + case KeyEvent.KEYCODE_MOVE_END: + pageScroll(View.FOCUS_DOWN); + break; + } + } + + return handled; + } + + private boolean inChild(int x, int y) { + if (getChildCount() > 0) { + final int scrollY = getScrollY(); + final View child = getChildAt(0); + return !(y < child.getTop() - scrollY + || y >= child.getBottom() - scrollY + || x < child.getLeft() + || x >= child.getRight()); + } + return false; + } + + private void initOrResetVelocityTracker() { + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } else { + mVelocityTracker.clear(); + } + } + + private void initVelocityTrackerIfNotExists() { + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + } + + private void recycleVelocityTracker() { + if (mVelocityTracker != null) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + } + + @Override + public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { + if (disallowIntercept) { + recycleVelocityTracker(); + } + super.requestDisallowInterceptTouchEvent(disallowIntercept); + } + + @Override + public boolean onInterceptTouchEvent(@NonNull MotionEvent ev) { + /* + * This method JUST determines whether we want to intercept the motion. + * If we return true, onMotionEvent will be called and we do the actual + * scrolling there. + */ + + /* + * Shortcut the most recurring case: the user is in the dragging + * state and they are moving their finger. We want to intercept this + * motion. + */ + final int action = ev.getAction(); + if ((action == MotionEvent.ACTION_MOVE) && mIsBeingDragged) { + return true; + } + + switch (action & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_MOVE: { + /* + * mIsBeingDragged == false, otherwise the shortcut would have caught it. Check + * whether the user has moved far enough from their original down touch. + */ + + /* + * Locally do absolute value. mLastMotionY is set to the y value + * of the down event. + */ + final int activePointerId = mActivePointerId; + if (activePointerId == INVALID_POINTER) { + // If we don't have a valid id, the touch down wasn't on content. + break; + } + + final int pointerIndex = ev.findPointerIndex(activePointerId); + if (pointerIndex == -1) { + Log.e(TAG, "Invalid pointerId=" + activePointerId + + " in onInterceptTouchEvent"); + break; + } + + final int y = (int) ev.getY(pointerIndex); + final int yDiff = Math.abs(y - mLastMotionY); + if (yDiff > mTouchSlop + && (getNestedScrollAxes() & ViewCompat.SCROLL_AXIS_VERTICAL) == 0) { + mIsBeingDragged = true; + mLastMotionY = y; + initVelocityTrackerIfNotExists(); + mVelocityTracker.addMovement(ev); + mNestedYOffset = 0; + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + } + break; + } + + case MotionEvent.ACTION_DOWN: { + final int y = (int) ev.getY(); + if (!inChild((int) ev.getX(), y)) { + mIsBeingDragged = stopGlowAnimations(ev) || !mScroller.isFinished(); + recycleVelocityTracker(); + break; + } + + /* + * Remember location of down touch. + * ACTION_DOWN always refers to pointer index 0. + */ + mLastMotionY = y; + mActivePointerId = ev.getPointerId(0); + + initOrResetVelocityTracker(); + mVelocityTracker.addMovement(ev); + /* + * If being flinged and user touches the screen, initiate drag; + * otherwise don't. mScroller.isFinished should be false when + * being flinged. We also want to catch the edge glow and start dragging + * if one is being animated. We need to call computeScrollOffset() first so that + * isFinished() is correct. + */ + mScroller.computeScrollOffset(); + mIsBeingDragged = stopGlowAnimations(ev) || !mScroller.isFinished(); + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH); + break; + } + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + /* Release the drag */ + mIsBeingDragged = false; + mActivePointerId = INVALID_POINTER; + recycleVelocityTracker(); + if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange())) { + postInvalidateOnAnimation(); + } + stopNestedScroll(ViewCompat.TYPE_TOUCH); + break; + case MotionEvent.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + } + + /* + * The only time we want to intercept motion events is if we are in the + * drag mode. + */ + return mIsBeingDragged; + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent motionEvent) { + initVelocityTrackerIfNotExists(); + + final int actionMasked = motionEvent.getActionMasked(); + + if (actionMasked == MotionEvent.ACTION_DOWN) { + mNestedYOffset = 0; + } + + MotionEvent velocityTrackerMotionEvent = MotionEvent.obtain(motionEvent); + velocityTrackerMotionEvent.offsetLocation(0, mNestedYOffset); + + switch (actionMasked) { + case MotionEvent.ACTION_DOWN: { + if (getChildCount() == 0) { + return false; + } + + // If additional fingers touch the screen while a drag is in progress, this block + // of code will make sure the drag isn't interrupted. + if (mIsBeingDragged) { + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + } + + /* + * If being flinged and user touches, stop the fling. isFinished + * will be false if being flinged. + */ + if (!mScroller.isFinished()) { + abortAnimatedScroll(); + } + + initializeTouchDrag( + (int) motionEvent.getY(), + motionEvent.getPointerId(0) + ); + + break; + } + + case MotionEvent.ACTION_MOVE: { + final int activePointerIndex = motionEvent.findPointerIndex(mActivePointerId); + if (activePointerIndex == -1) { + Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent"); + break; + } + + final int y = (int) motionEvent.getY(activePointerIndex); + int deltaY = mLastMotionY - y; + deltaY -= releaseVerticalGlow(deltaY, motionEvent.getX(activePointerIndex)); + + // Changes to dragged state if delta is greater than the slop (and not in + // the dragged state). + if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) { + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + mIsBeingDragged = true; + if (deltaY > 0) { + deltaY -= mTouchSlop; + } else { + deltaY += mTouchSlop; + } + } + + if (mIsBeingDragged) { + final int x = (int) motionEvent.getX(activePointerIndex); + int scrollOffset = scrollBy(deltaY, x, ViewCompat.TYPE_TOUCH, false); + // Updates the global positions (used by later move events to properly scroll). + mLastMotionY = y - scrollOffset; + mNestedYOffset += scrollOffset; + } + break; + } + + case MotionEvent.ACTION_UP: { + final VelocityTracker velocityTracker = mVelocityTracker; + velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); + int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId); + if ((Math.abs(initialVelocity) >= mMinimumVelocity)) { + if (!edgeEffectFling(initialVelocity) + && !dispatchNestedPreFling(0, -initialVelocity)) { + dispatchNestedFling(0, -initialVelocity, true); + fling(-initialVelocity); + } + } else if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, + getScrollRange())) { + postInvalidateOnAnimation(); + } + endTouchDrag(); + break; + } + + case MotionEvent.ACTION_CANCEL: { + if (mIsBeingDragged && getChildCount() > 0) { + if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, + getScrollRange())) { + postInvalidateOnAnimation(); + } + } + endTouchDrag(); + break; + } + + case MotionEvent.ACTION_POINTER_DOWN: { + final int index = motionEvent.getActionIndex(); + mLastMotionY = (int) motionEvent.getY(index); + mActivePointerId = motionEvent.getPointerId(index); + break; + } + + case MotionEvent.ACTION_POINTER_UP: { + onSecondaryPointerUp(motionEvent); + mLastMotionY = + (int) motionEvent.getY(motionEvent.findPointerIndex(mActivePointerId)); + break; + } + } + + if (mVelocityTracker != null) { + mVelocityTracker.addMovement(velocityTrackerMotionEvent); + } + // Returns object back to be re-used by others. + velocityTrackerMotionEvent.recycle(); + + return true; + } + + private void initializeTouchDrag(int lastMotionY, int activePointerId) { + mLastMotionY = lastMotionY; + mActivePointerId = activePointerId; + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH); + } + + // Ends drag in a nested scroll. + private void endTouchDrag() { + mActivePointerId = INVALID_POINTER; + mIsBeingDragged = false; + + recycleVelocityTracker(); + stopNestedScroll(ViewCompat.TYPE_TOUCH); + + mEdgeGlowTop.onRelease(); + mEdgeGlowBottom.onRelease(); + } + + /* + * Handles scroll events for both touch and non-touch events (mouse scroll wheel, + * rotary button, keyboard, etc.). + * + * Note: This function returns the total scroll offset for this scroll event which is required + * for calculating the total scroll between multiple move events (touch). This returned value + * is NOT needed for non-touch events since a scroll is a one time event (vs. touch where a + * drag may be triggered multiple times with the movement of the finger). + */ + // TODO: You should rename this to nestedScrollBy() so it is different from View.scrollBy + private int scrollBy( + int verticalScrollDistance, + int x, + int touchType, + boolean isSourceMouseOrKeyboard + ) { + int totalScrollOffset = 0; + + /* + * Starts nested scrolling for non-touch events (mouse scroll wheel, rotary button, etc.). + * This is in contrast to a touch event which would trigger the start of nested scrolling + * with a touch down event outside of this method, since for a single gesture scrollBy() + * might be called several times for a move event for a single drag gesture. + */ + if (touchType == ViewCompat.TYPE_NON_TOUCH) { + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, touchType); + } + + // Dispatches scrolling delta amount available to parent (to consume what it needs). + // Note: The amounts the parent consumes are saved in arrays named mScrollConsumed and + // mScrollConsumed to save space. + if (dispatchNestedPreScroll( + 0, + verticalScrollDistance, + mScrollConsumed, + mScrollOffset, + touchType) + ) { + // Deducts the scroll amount (y) consumed by the parent (x in position 0, + // y in position 1). Nested scroll only works with Y position (so we don't use x). + verticalScrollDistance -= mScrollConsumed[1]; + totalScrollOffset += mScrollOffset[1]; + } + + // Retrieves the scroll y position (top position of this view) and scroll Y range (how far + // the scroll can go). + final int initialScrollY = getScrollY(); + final int scrollRangeY = getScrollRange(); + + // Overscroll is for adding animations at the top/bottom of a view when the user scrolls + // beyond the beginning/end of the view. Overscroll is not used with a mouse. + boolean canOverscroll = canOverScroll() && !isSourceMouseOrKeyboard; + + // Scrolls content in the current View, but clamps it if it goes too far. + boolean hitScrollBarrier = + overScrollByCompat( + 0, + verticalScrollDistance, + 0, + initialScrollY, + 0, + scrollRangeY, + 0, + 0, + true + ) && !hasNestedScrollingParent(touchType); + + // The position may have been adjusted in the previous call, so we must revise our values. + final int scrollYDelta = getScrollY() - initialScrollY; + final int unconsumedY = verticalScrollDistance - scrollYDelta; + + // Reset the Y consumed scroll to zero + mScrollConsumed[1] = 0; + + // Dispatch the unconsumed delta Y to the children to consume. + dispatchNestedScroll( + 0, + scrollYDelta, + 0, + unconsumedY, + mScrollOffset, + touchType, + mScrollConsumed + ); + + totalScrollOffset += mScrollOffset[1]; + + // Handle overscroll of the children. + verticalScrollDistance -= mScrollConsumed[1]; + int newScrollY = initialScrollY + verticalScrollDistance; + + if (newScrollY < 0) { + if (canOverscroll) { + EdgeEffectCompat.onPullDistance( + mEdgeGlowTop, + (float) -verticalScrollDistance / getHeight(), + (float) x / getWidth() + ); + + if (!mEdgeGlowBottom.isFinished()) { + mEdgeGlowBottom.onRelease(); + } + } + + } else if (newScrollY > scrollRangeY) { + if (canOverscroll) { + EdgeEffectCompat.onPullDistance( + mEdgeGlowBottom, + (float) verticalScrollDistance / getHeight(), + 1.f - ((float) x / getWidth()) + ); + + if (!mEdgeGlowTop.isFinished()) { + mEdgeGlowTop.onRelease(); + } + } + } + + if (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished()) { + postInvalidateOnAnimation(); + hitScrollBarrier = false; + } + + if (hitScrollBarrier && (touchType == ViewCompat.TYPE_TOUCH)) { + // Break our velocity if we hit a scroll barrier. + if (mVelocityTracker != null) { + mVelocityTracker.clear(); + } + } + + /* + * Ends nested scrolling for non-touch events (mouse scroll wheel, rotary button, etc.). + * As noted above, this is in contrast to a touch event. + */ + if (touchType == ViewCompat.TYPE_NON_TOUCH) { + stopNestedScroll(touchType); + + // Required for scrolling with Rotary Device stretch top/bottom to work properly + mEdgeGlowTop.onRelease(); + mEdgeGlowBottom.onRelease(); + } + + return totalScrollOffset; + } + + /** + * Returns true if edgeEffect should call onAbsorb() with veclocity or false if it should + * animate with a fling. It will animate with a fling if the velocity will remove the + * EdgeEffect through its normal operation. + * + * @param edgeEffect The EdgeEffect that might absorb the velocity. + * @param velocity The velocity of the fling motion + * @return true if the velocity should be absorbed or false if it should be flung. + */ + private boolean shouldAbsorb(@NonNull EdgeEffect edgeEffect, int velocity) { + if (velocity > 0) { + return true; + } + float distance = EdgeEffectCompat.getDistance(edgeEffect) * getHeight(); + + // This is flinging without the spring, so let's see if it will fling past the overscroll + float flingDistance = getSplineFlingDistance(-velocity); + + return flingDistance < distance; + } + + /** + * If mTopGlow or mBottomGlow is currently active and the motion will remove some of the + * stretch, this will consume any of unconsumedY that the glow can. If the motion would + * increase the stretch, or the EdgeEffect isn't a stretch, then nothing will be consumed. + * + * @param unconsumedY The vertical delta that might be consumed by the vertical EdgeEffects + * @return The remaining unconsumed delta after the edge effects have consumed. + */ + int consumeFlingInVerticalStretch(int unconsumedY) { + int height = getHeight(); + if (unconsumedY > 0 && EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0f) { + float deltaDistance = -unconsumedY * FLING_DESTRETCH_FACTOR / height; + int consumed = Math.round(-height / FLING_DESTRETCH_FACTOR + * EdgeEffectCompat.onPullDistance(mEdgeGlowTop, deltaDistance, 0.5f)); + if (consumed != unconsumedY) { + mEdgeGlowTop.finish(); + } + return unconsumedY - consumed; + } + if (unconsumedY < 0 && EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0f) { + float deltaDistance = unconsumedY * FLING_DESTRETCH_FACTOR / height; + int consumed = Math.round(height / FLING_DESTRETCH_FACTOR + * EdgeEffectCompat.onPullDistance(mEdgeGlowBottom, deltaDistance, 0.5f)); + if (consumed != unconsumedY) { + mEdgeGlowBottom.finish(); + } + return unconsumedY - consumed; + } + return unconsumedY; + } + + /** + * Copied from OverScroller, this returns the distance that a fling with the given velocity + * will go. + * @param velocity The velocity of the fling + * @return The distance that will be traveled by a fling of the given velocity. + */ + private float getSplineFlingDistance(int velocity) { + final double l = + Math.log(INFLEXION * Math.abs(velocity) / (SCROLL_FRICTION * mPhysicalCoeff)); + final double decelMinusOne = DECELERATION_RATE - 1.0; + return (float) (SCROLL_FRICTION * mPhysicalCoeff + * Math.exp(DECELERATION_RATE / decelMinusOne * l)); + } + + private boolean edgeEffectFling(int velocityY) { + boolean consumed = true; + if (EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0) { + if (shouldAbsorb(mEdgeGlowTop, velocityY)) { + mEdgeGlowTop.onAbsorb(velocityY); + } else { + fling(-velocityY); + } + } else if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0) { + if (shouldAbsorb(mEdgeGlowBottom, -velocityY)) { + mEdgeGlowBottom.onAbsorb(-velocityY); + } else { + fling(-velocityY); + } + } else { + consumed = false; + } + return consumed; + } + + /** + * This stops any edge glow animation that is currently running by applying a + * 0 length pull at the displacement given by the provided MotionEvent. On pre-S devices, + * this method does nothing, allowing any animating edge effect to continue animating and + * returning <code>false</code> always. + * + * @param e The motion event to use to indicate the finger position for the displacement of + * the current pull. + * @return <code>true</code> if any edge effect had an existing effect to be drawn ond the + * animation was stopped or <code>false</code> if no edge effect had a value to display. + */ + private boolean stopGlowAnimations(MotionEvent e) { + boolean stopped = false; + if (EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0) { + EdgeEffectCompat.onPullDistance(mEdgeGlowTop, 0, e.getX() / getWidth()); + stopped = true; + } + if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0) { + EdgeEffectCompat.onPullDistance(mEdgeGlowBottom, 0, 1 - e.getX() / getWidth()); + stopped = true; + } + return stopped; + } + + private void onSecondaryPointerUp(MotionEvent ev) { + final int pointerIndex = ev.getActionIndex(); + final int pointerId = ev.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + // TODO: Make this decision more intelligent. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mLastMotionY = (int) ev.getY(newPointerIndex); + mActivePointerId = ev.getPointerId(newPointerIndex); + if (mVelocityTracker != null) { + mVelocityTracker.clear(); + } + } + } + + @Override + public boolean onGenericMotionEvent(@NonNull MotionEvent motionEvent) { + if (motionEvent.getAction() == MotionEvent.ACTION_SCROLL && !mIsBeingDragged) { + final float verticalScroll; + final int x; + final int flingAxis; + + if (MotionEventCompat.isFromSource(motionEvent, InputDevice.SOURCE_CLASS_POINTER)) { + verticalScroll = motionEvent.getAxisValue(MotionEvent.AXIS_VSCROLL); + x = (int) motionEvent.getX(); + flingAxis = MotionEvent.AXIS_VSCROLL; + } else if ( + MotionEventCompat.isFromSource(motionEvent, InputDevice.SOURCE_ROTARY_ENCODER) + ) { + verticalScroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL); + // Since a Wear rotary event doesn't have a true X and we want to support proper + // overscroll animations, we put the x at the center of the screen. + x = getWidth() / 2; + flingAxis = MotionEvent.AXIS_SCROLL; + } else { + verticalScroll = 0; + x = 0; + flingAxis = 0; + } + + if (verticalScroll != 0) { + // Rotary and Mouse scrolls are inverted from a touch scroll. + final int invertedDelta = (int) (verticalScroll * getVerticalScrollFactorCompat()); + + final boolean isSourceMouse = + MotionEventCompat.isFromSource(motionEvent, InputDevice.SOURCE_MOUSE); + + scrollBy(-invertedDelta, x, ViewCompat.TYPE_NON_TOUCH, isSourceMouse); + if (flingAxis != 0) { + mDifferentialMotionFlingController.onMotionEvent(motionEvent, flingAxis); + } + + return true; + } + } + return false; + } + + /** + * Returns true if the NestedScrollView supports over scroll. + */ + private boolean canOverScroll() { + final int mode = getOverScrollMode(); + return mode == OVER_SCROLL_ALWAYS + || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && getScrollRange() > 0); + } + + @VisibleForTesting + float getVerticalScrollFactorCompat() { + if (mVerticalScrollFactor == 0) { + TypedValue outValue = new TypedValue(); + final Context context = getContext(); + if (!context.getTheme().resolveAttribute( + android.R.attr.listPreferredItemHeight, outValue, true)) { + throw new IllegalStateException( + "Expected theme to define listPreferredItemHeight."); + } + mVerticalScrollFactor = outValue.getDimension( + context.getResources().getDisplayMetrics()); + } + return mVerticalScrollFactor; + } + + @Override + protected void onOverScrolled(int scrollX, int scrollY, + boolean clampedX, boolean clampedY) { + super.scrollTo(scrollX, scrollY); + } + + @SuppressWarnings({"SameParameterValue", "unused"}) + boolean overScrollByCompat(int deltaX, int deltaY, + int scrollX, int scrollY, + int scrollRangeX, int scrollRangeY, + int maxOverScrollX, int maxOverScrollY, + boolean isTouchEvent) { + + final int overScrollMode = getOverScrollMode(); + final boolean canScrollHorizontal = + computeHorizontalScrollRange() > computeHorizontalScrollExtent(); + final boolean canScrollVertical = + computeVerticalScrollRange() > computeVerticalScrollExtent(); + + final boolean overScrollHorizontal = overScrollMode == View.OVER_SCROLL_ALWAYS + || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollHorizontal); + final boolean overScrollVertical = overScrollMode == View.OVER_SCROLL_ALWAYS + || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollVertical); + + int newScrollX = scrollX + deltaX; + if (!overScrollHorizontal) { + maxOverScrollX = 0; + } + + int newScrollY = scrollY + deltaY; + if (!overScrollVertical) { + maxOverScrollY = 0; + } + + // Clamp values if at the limits and record + final int left = -maxOverScrollX; + final int right = maxOverScrollX + scrollRangeX; + final int top = -maxOverScrollY; + final int bottom = maxOverScrollY + scrollRangeY; + + boolean clampedX = false; + if (newScrollX > right) { + newScrollX = right; + clampedX = true; + } else if (newScrollX < left) { + newScrollX = left; + clampedX = true; + } + + boolean clampedY = false; + if (newScrollY > bottom) { + newScrollY = bottom; + clampedY = true; + } else if (newScrollY < top) { + newScrollY = top; + clampedY = true; + } + + if (clampedY && !hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) { + mScroller.springBack(newScrollX, newScrollY, 0, 0, 0, getScrollRange()); + } + + onOverScrolled(newScrollX, newScrollY, clampedX, clampedY); + + return clampedX || clampedY; + } + + int getScrollRange() { + int scrollRange = 0; + if (getChildCount() > 0) { + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int childSize = child.getHeight() + lp.topMargin + lp.bottomMargin; + int parentSpace = getHeight() - getPaddingTop() - getPaddingBottom(); + scrollRange = Math.max(0, childSize - parentSpace); + } + return scrollRange; + } + + /** + * <p> + * Finds the next focusable component that fits in the specified bounds. + * </p> + * + * @param topFocus look for a candidate is the one at the top of the bounds + * if topFocus is true, or at the bottom of the bounds if topFocus is + * false + * @param top the top offset of the bounds in which a focusable must be + * found + * @param bottom the bottom offset of the bounds in which a focusable must + * be found + * @return the next focusable component in the bounds or null if none can + * be found + */ + private View findFocusableViewInBounds(boolean topFocus, int top, int bottom) { + + List<View> focusables = getFocusables(View.FOCUS_FORWARD); + View focusCandidate = null; + + /* + * A fully contained focusable is one where its top is below the bound's + * top, and its bottom is above the bound's bottom. A partially + * contained focusable is one where some part of it is within the + * bounds, but it also has some part that is not within bounds. A fully contained + * focusable is preferred to a partially contained focusable. + */ + boolean foundFullyContainedFocusable = false; + + int count = focusables.size(); + for (int i = 0; i < count; i++) { + View view = focusables.get(i); + int viewTop = view.getTop(); + int viewBottom = view.getBottom(); + + if (top < viewBottom && viewTop < bottom) { + /* + * the focusable is in the target area, it is a candidate for + * focusing + */ + + final boolean viewIsFullyContained = (top < viewTop) && (viewBottom < bottom); + + if (focusCandidate == null) { + /* No candidate, take this one */ + focusCandidate = view; + foundFullyContainedFocusable = viewIsFullyContained; + } else { + final boolean viewIsCloserToBoundary = + (topFocus && viewTop < focusCandidate.getTop()) + || (!topFocus && viewBottom > focusCandidate.getBottom()); + + if (foundFullyContainedFocusable) { + if (viewIsFullyContained && viewIsCloserToBoundary) { + /* + * We're dealing with only fully contained views, so + * it has to be closer to the boundary to beat our + * candidate + */ + focusCandidate = view; + } + } else { + if (viewIsFullyContained) { + /* Any fully contained view beats a partially contained view */ + focusCandidate = view; + foundFullyContainedFocusable = true; + } else if (viewIsCloserToBoundary) { + /* + * Partially contained view beats another partially + * contained view if it's closer + */ + focusCandidate = view; + } + } + } + } + } + + return focusCandidate; + } + + /** + * <p>Handles scrolling in response to a "page up/down" shortcut press. This + * method will scroll the view by one page up or down and give the focus + * to the topmost/bottommost component in the new visible area. If no + * component is a good candidate for focus, this scrollview reclaims the + * focus.</p> + * + * @param direction the scroll direction: {@link View#FOCUS_UP} + * to go one page up or + * {@link View#FOCUS_DOWN} to go one page down + * @return true if the key event is consumed by this method, false otherwise + */ + public boolean pageScroll(int direction) { + boolean down = direction == View.FOCUS_DOWN; + int height = getHeight(); + + if (down) { + mTempRect.top = getScrollY() + height; + int count = getChildCount(); + if (count > 0) { + View view = getChildAt(count - 1); + LayoutParams lp = (LayoutParams) view.getLayoutParams(); + int bottom = view.getBottom() + lp.bottomMargin + getPaddingBottom(); + if (mTempRect.top + height > bottom) { + mTempRect.top = bottom - height; + } + } + } else { + mTempRect.top = getScrollY() - height; + if (mTempRect.top < 0) { + mTempRect.top = 0; + } + } + mTempRect.bottom = mTempRect.top + height; + + return scrollAndFocus(direction, mTempRect.top, mTempRect.bottom); + } + + /** + * <p>Handles scrolling in response to a "home/end" shortcut press. This + * method will scroll the view to the top or bottom and give the focus + * to the topmost/bottommost component in the new visible area. If no + * component is a good candidate for focus, this scrollview reclaims the + * focus.</p> + * + * @param direction the scroll direction: {@link View#FOCUS_UP} + * to go the top of the view or + * {@link View#FOCUS_DOWN} to go the bottom + * @return true if the key event is consumed by this method, false otherwise + */ + public boolean fullScroll(int direction) { + boolean down = direction == View.FOCUS_DOWN; + int height = getHeight(); + + mTempRect.top = 0; + mTempRect.bottom = height; + + if (down) { + int count = getChildCount(); + if (count > 0) { + View view = getChildAt(count - 1); + LayoutParams lp = (LayoutParams) view.getLayoutParams(); + mTempRect.bottom = view.getBottom() + lp.bottomMargin + getPaddingBottom(); + mTempRect.top = mTempRect.bottom - height; + } + } + return scrollAndFocus(direction, mTempRect.top, mTempRect.bottom); + } + + /** + * <p>Scrolls the view to make the area defined by <code>top</code> and + * <code>bottom</code> visible. This method attempts to give the focus + * to a component visible in this area. If no component can be focused in + * the new visible area, the focus is reclaimed by this ScrollView.</p> + * + * @param direction the scroll direction: {@link View#FOCUS_UP} + * to go upward, {@link View#FOCUS_DOWN} to downward + * @param top the top offset of the new area to be made visible + * @param bottom the bottom offset of the new area to be made visible + * @return true if the key event is consumed by this method, false otherwise + */ + private boolean scrollAndFocus(int direction, int top, int bottom) { + boolean handled = true; + + int height = getHeight(); + int containerTop = getScrollY(); + int containerBottom = containerTop + height; + boolean up = direction == View.FOCUS_UP; + + View newFocused = findFocusableViewInBounds(up, top, bottom); + if (newFocused == null) { + newFocused = this; + } + + if (top >= containerTop && bottom <= containerBottom) { + handled = false; + } else { + int delta = up ? (top - containerTop) : (bottom - containerBottom); + scrollBy(delta, 0, ViewCompat.TYPE_NON_TOUCH, true); + } + + if (newFocused != findFocus()) newFocused.requestFocus(direction); + + return handled; + } + + /** + * Handle scrolling in response to an up or down arrow click. + * + * @param direction The direction corresponding to the arrow key that was + * pressed + * @return True if we consumed the event, false otherwise + */ + public boolean arrowScroll(int direction) { + View currentFocused = findFocus(); + if (currentFocused == this) currentFocused = null; + + View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction); + + final int maxJump = getMaxScrollAmount(); + + if (nextFocused != null && isWithinDeltaOfScreen(nextFocused, maxJump, getHeight())) { + nextFocused.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(nextFocused, mTempRect); + int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect); + + scrollBy(scrollDelta, 0, ViewCompat.TYPE_NON_TOUCH, true); + nextFocused.requestFocus(direction); + + } else { + // no new focus + int scrollDelta = maxJump; + + if (direction == View.FOCUS_UP && getScrollY() < scrollDelta) { + scrollDelta = getScrollY(); + } else if (direction == View.FOCUS_DOWN) { + if (getChildCount() > 0) { + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int daBottom = child.getBottom() + lp.bottomMargin; + int screenBottom = getScrollY() + getHeight() - getPaddingBottom(); + scrollDelta = Math.min(daBottom - screenBottom, maxJump); + } + } + if (scrollDelta == 0) { + return false; + } + + int finalScrollDelta = direction == View.FOCUS_DOWN ? scrollDelta : -scrollDelta; + scrollBy(finalScrollDelta, 0, ViewCompat.TYPE_NON_TOUCH, true); + } + + if (currentFocused != null && currentFocused.isFocused() + && isOffScreen(currentFocused)) { + // previously focused item still has focus and is off screen, give + // it up (take it back to ourselves) + // (also, need to temporarily force FOCUS_BEFORE_DESCENDANTS so we are + // sure to + // get it) + final int descendantFocusability = getDescendantFocusability(); // save + setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS); + requestFocus(); + setDescendantFocusability(descendantFocusability); // restore + } + return true; + } + + /** + * @return whether the descendant of this scroll view is scrolled off + * screen. + */ + private boolean isOffScreen(View descendant) { + return !isWithinDeltaOfScreen(descendant, 0, getHeight()); + } + + /** + * @return whether the descendant of this scroll view is within delta + * pixels of being on the screen. + */ + private boolean isWithinDeltaOfScreen(View descendant, int delta, int height) { + descendant.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(descendant, mTempRect); + + return (mTempRect.bottom + delta) >= getScrollY() + && (mTempRect.top - delta) <= (getScrollY() + height); + } + + /** + * Smooth scroll by a Y delta + * + * @param delta the number of pixels to scroll by on the Y axis + */ + private void doScrollY(int delta) { + if (delta != 0) { + if (mSmoothScrollingEnabled) { + smoothScrollBy(0, delta); + } else { + scrollBy(0, delta); + } + } + } + + /** + * Like {@link View#scrollBy}, but scroll smoothly instead of immediately. + * + * @param dx the number of pixels to scroll by on the X axis + * @param dy the number of pixels to scroll by on the Y axis + */ + public final void smoothScrollBy(int dx, int dy) { + smoothScrollBy(dx, dy, DEFAULT_SMOOTH_SCROLL_DURATION, false); + } + + /** + * Like {@link View#scrollBy}, but scroll smoothly instead of immediately. + * + * @param dx the number of pixels to scroll by on the X axis + * @param dy the number of pixels to scroll by on the Y axis + * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds + */ + public final void smoothScrollBy(int dx, int dy, int scrollDurationMs) { + smoothScrollBy(dx, dy, scrollDurationMs, false); + } + + /** + * Like {@link View#scrollBy}, but scroll smoothly instead of immediately. + * + * @param dx the number of pixels to scroll by on the X axis + * @param dy the number of pixels to scroll by on the Y axis + * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds + * @param withNestedScrolling whether to include nested scrolling operations. + */ + private void smoothScrollBy(int dx, int dy, int scrollDurationMs, boolean withNestedScrolling) { + if (getChildCount() == 0) { + // Nothing to do. + return; + } + long duration = AnimationUtils.currentAnimationTimeMillis() - mLastScroll; + if (duration > ANIMATED_SCROLL_GAP) { + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int childSize = child.getHeight() + lp.topMargin + lp.bottomMargin; + int parentSpace = getHeight() - getPaddingTop() - getPaddingBottom(); + final int scrollY = getScrollY(); + final int maxY = Math.max(0, childSize - parentSpace); + dy = Math.max(0, Math.min(scrollY + dy, maxY)) - scrollY; + mScroller.startScroll(getScrollX(), scrollY, 0, dy, scrollDurationMs); + runAnimatedScroll(withNestedScrolling); + } else { + if (!mScroller.isFinished()) { + abortAnimatedScroll(); + } + scrollBy(dx, dy); + } + mLastScroll = AnimationUtils.currentAnimationTimeMillis(); + } + + /** + * Like {@link #scrollTo}, but scroll smoothly instead of immediately. + * + * @param x the position where to scroll on the X axis + * @param y the position where to scroll on the Y axis + */ + public final void smoothScrollTo(int x, int y) { + smoothScrollTo(x, y, DEFAULT_SMOOTH_SCROLL_DURATION, false); + } + + /** + * Like {@link #scrollTo}, but scroll smoothly instead of immediately. + * + * @param x the position where to scroll on the X axis + * @param y the position where to scroll on the Y axis + * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds + */ + public final void smoothScrollTo(int x, int y, int scrollDurationMs) { + smoothScrollTo(x, y, scrollDurationMs, false); + } + + /** + * Like {@link #scrollTo}, but scroll smoothly instead of immediately. + * + * @param x the position where to scroll on the X axis + * @param y the position where to scroll on the Y axis + * @param withNestedScrolling whether to include nested scrolling operations. + */ + // This should be considered private, it is package private to avoid a synthetic ancestor. + @SuppressWarnings("SameParameterValue") + void smoothScrollTo(int x, int y, boolean withNestedScrolling) { + smoothScrollTo(x, y, DEFAULT_SMOOTH_SCROLL_DURATION, withNestedScrolling); + } + + /** + * Like {@link #scrollTo}, but scroll smoothly instead of immediately. + * + * @param x the position where to scroll on the X axis + * @param y the position where to scroll on the Y axis + * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds + * @param withNestedScrolling whether to include nested scrolling operations. + */ + // This should be considered private, it is package private to avoid a synthetic ancestor. + void smoothScrollTo(int x, int y, int scrollDurationMs, boolean withNestedScrolling) { + smoothScrollBy(x - getScrollX(), y - getScrollY(), scrollDurationMs, withNestedScrolling); + } + + /** + * <p>The scroll range of a scroll view is the overall height of all of its + * children.</p> + */ + @Override + public int computeVerticalScrollRange() { + final int count = getChildCount(); + final int parentSpace = getHeight() - getPaddingBottom() - getPaddingTop(); + if (count == 0) { + return parentSpace; + } + + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int scrollRange = child.getBottom() + lp.bottomMargin; + final int scrollY = getScrollY(); + final int overscrollBottom = Math.max(0, scrollRange - parentSpace); + if (scrollY < 0) { + scrollRange -= scrollY; + } else if (scrollY > overscrollBottom) { + scrollRange += scrollY - overscrollBottom; + } + + return scrollRange; + } + + @Override + public int computeVerticalScrollOffset() { + return Math.max(0, super.computeVerticalScrollOffset()); + } + + @Override + public int computeVerticalScrollExtent() { + return super.computeVerticalScrollExtent(); + } + + @Override + public int computeHorizontalScrollRange() { + return super.computeHorizontalScrollRange(); + } + + @Override + public int computeHorizontalScrollOffset() { + return super.computeHorizontalScrollOffset(); + } + + @Override + public int computeHorizontalScrollExtent() { + return super.computeHorizontalScrollExtent(); + } + + @Override + protected void measureChild(@NonNull View child, int parentWidthMeasureSpec, + int parentHeightMeasureSpec) { + ViewGroup.LayoutParams lp = child.getLayoutParams(); + + int childWidthMeasureSpec; + int childHeightMeasureSpec; + + childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, getPaddingLeft() + + getPaddingRight(), lp.width); + + childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + + @Override + protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, + int parentHeightMeasureSpec, int heightUsed) { + final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); + + final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, + getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin + + widthUsed, lp.width); + final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED); + + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + + @Override + public void computeScroll() { + + if (mScroller.isFinished()) { + return; + } + + mScroller.computeScrollOffset(); + final int y = mScroller.getCurrY(); + int unconsumed = consumeFlingInVerticalStretch(y - mLastScrollerY); + mLastScrollerY = y; + + // Nested Scrolling Pre Pass + mScrollConsumed[1] = 0; + dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null, + ViewCompat.TYPE_NON_TOUCH); + unconsumed -= mScrollConsumed[1]; + + final int range = getScrollRange(); + + if (unconsumed != 0) { + // Internal Scroll + final int oldScrollY = getScrollY(); + overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false); + final int scrolledByMe = getScrollY() - oldScrollY; + unconsumed -= scrolledByMe; + + // Nested Scrolling Post Pass + mScrollConsumed[1] = 0; + dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset, + ViewCompat.TYPE_NON_TOUCH, mScrollConsumed); + unconsumed -= mScrollConsumed[1]; + } + + if (unconsumed != 0) { + final int mode = getOverScrollMode(); + final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS + || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0); + if (canOverscroll) { + if (unconsumed < 0) { + if (mEdgeGlowTop.isFinished()) { + mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity()); + } + } else { + if (mEdgeGlowBottom.isFinished()) { + mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity()); + } + } + } + abortAnimatedScroll(); + } + + if (!mScroller.isFinished()) { + postInvalidateOnAnimation(); + } else { + stopNestedScroll(ViewCompat.TYPE_NON_TOUCH); + } + } + + /** + * If either of the vertical edge glows are currently active, this consumes part or all of + * deltaY on the edge glow. + * + * @param deltaY The pointer motion, in pixels, in the vertical direction, positive + * for moving down and negative for moving up. + * @param x The vertical position of the pointer. + * @return The amount of <code>deltaY</code> that has been consumed by the + * edge glow. + */ + private int releaseVerticalGlow(int deltaY, float x) { + // First allow releasing existing overscroll effect: + float consumed = 0; + float displacement = x / getWidth(); + float pullDistance = (float) deltaY / getHeight(); + if (EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0) { + consumed = -EdgeEffectCompat.onPullDistance(mEdgeGlowTop, -pullDistance, displacement); + if (EdgeEffectCompat.getDistance(mEdgeGlowTop) == 0) { + mEdgeGlowTop.onRelease(); + } + } else if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0) { + consumed = EdgeEffectCompat.onPullDistance(mEdgeGlowBottom, pullDistance, + 1 - displacement); + if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) == 0) { + mEdgeGlowBottom.onRelease(); + } + } + int pixelsConsumed = Math.round(consumed * getHeight()); + if (pixelsConsumed != 0) { + invalidate(); + } + return pixelsConsumed; + } + + private void runAnimatedScroll(boolean participateInNestedScrolling) { + if (participateInNestedScrolling) { + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH); + } else { + stopNestedScroll(ViewCompat.TYPE_NON_TOUCH); + } + mLastScrollerY = getScrollY(); + postInvalidateOnAnimation(); + } + + private void abortAnimatedScroll() { + mScroller.abortAnimation(); + stopNestedScroll(ViewCompat.TYPE_NON_TOUCH); + } + + /** + * Scrolls the view to the given child. + * + * @param child the View to scroll to + */ + private void scrollToChild(View child) { + child.getDrawingRect(mTempRect); + + /* Offset from child's local coordinates to ScrollView coordinates */ + offsetDescendantRectToMyCoords(child, mTempRect); + + int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect); + + if (scrollDelta != 0) { + scrollBy(0, scrollDelta); + } + } + + /** + * If rect is off screen, scroll just enough to get it (or at least the + * first screen size chunk of it) on screen. + * + * @param rect The rectangle. + * @param immediate True to scroll immediately without animation + * @return true if scrolling was performed + */ + private boolean scrollToChildRect(Rect rect, boolean immediate) { + final int delta = computeScrollDeltaToGetChildRectOnScreen(rect); + final boolean scroll = delta != 0; + if (scroll) { + if (immediate) { + scrollBy(0, delta); + } else { + smoothScrollBy(0, delta); + } + } + return scroll; + } + + /** + * Compute the amount to scroll in the Y direction in order to get + * a rectangle completely on the screen (or, if taller than the screen, + * at least the first screen size chunk of it). + * + * @param rect The rect. + * @return The scroll delta. + */ + protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) { + if (getChildCount() == 0) return 0; + + int height = getHeight(); + int screenTop = getScrollY(); + int screenBottom = screenTop + height; + int actualScreenBottom = screenBottom; + + int fadingEdge = getVerticalFadingEdgeLength(); + + // TODO: screenTop should be incremented by fadingEdge * getTopFadingEdgeStrength (but for + // the target scroll distance). + // leave room for top fading edge as long as rect isn't at very top + if (rect.top > 0) { + screenTop += fadingEdge; + } + + // TODO: screenBottom should be decremented by fadingEdge * getBottomFadingEdgeStrength (but + // for the target scroll distance). + // leave room for bottom fading edge as long as rect isn't at very bottom + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (rect.bottom < child.getHeight() + lp.topMargin + lp.bottomMargin) { + screenBottom -= fadingEdge; + } + + int scrollYDelta = 0; + + if (rect.bottom > screenBottom && rect.top > screenTop) { + // need to move down to get it in view: move down just enough so + // that the entire rectangle is in view (or at least the first + // screen size chunk). + + if (rect.height() > height) { + // just enough to get screen size chunk on + scrollYDelta += (rect.top - screenTop); + } else { + // get entire rect at bottom of screen + scrollYDelta += (rect.bottom - screenBottom); + } + + // make sure we aren't scrolling beyond the end of our content + int bottom = child.getBottom() + lp.bottomMargin; + int distanceToBottom = bottom - actualScreenBottom; + scrollYDelta = Math.min(scrollYDelta, distanceToBottom); + + } else if (rect.top < screenTop && rect.bottom < screenBottom) { + // need to move up to get it in view: move up just enough so that + // entire rectangle is in view (or at least the first screen + // size chunk of it). + + if (rect.height() > height) { + // screen size chunk + scrollYDelta -= (screenBottom - rect.bottom); + } else { + // entire rect at top + scrollYDelta -= (screenTop - rect.top); + } + + // make sure we aren't scrolling any further than the top our content + scrollYDelta = Math.max(scrollYDelta, -getScrollY()); + } + return scrollYDelta; + } + + @Override + public void requestChildFocus(View child, View focused) { + onRequestChildFocus(child, focused); + super.requestChildFocus(child, focused); + } + + protected void onRequestChildFocus(View child, View focused) { + if (!mIsLayoutDirty) { + scrollToChild(focused); + } else { + // The child may not be laid out yet, we can't compute the scroll yet + mChildToScrollTo = focused; + } + } + + + /** + * When looking for focus in children of a scroll view, need to be a little + * more careful not to give focus to something that is scrolled off screen. + * + * This is more expensive than the default {@link ViewGroup} + * implementation, otherwise this behavior might have been made the default. + */ + @Override + protected boolean onRequestFocusInDescendants(int direction, + Rect previouslyFocusedRect) { + + // convert from forward / backward notation to up / down / left / right + // (ugh). + if (direction == View.FOCUS_FORWARD) { + direction = View.FOCUS_DOWN; + } else if (direction == View.FOCUS_BACKWARD) { + direction = View.FOCUS_UP; + } + + final View nextFocus = previouslyFocusedRect == null + ? FocusFinder.getInstance().findNextFocus(this, null, direction) + : FocusFinder.getInstance().findNextFocusFromRect( + this, previouslyFocusedRect, direction); + + if (nextFocus == null) { + return false; + } + + if (isOffScreen(nextFocus)) { + return false; + } + + return nextFocus.requestFocus(direction, previouslyFocusedRect); + } + + @Override + public boolean requestChildRectangleOnScreen(@NonNull View child, Rect rectangle, + boolean immediate) { + // offset into coordinate space of this scroll view + rectangle.offset(child.getLeft() - child.getScrollX(), + child.getTop() - child.getScrollY()); + + return scrollToChildRect(rectangle, immediate); + } + + @Override + public void requestLayout() { + mIsLayoutDirty = true; + super.requestLayout(); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + mIsLayoutDirty = false; + // Give a child focus if it needs it + if (mChildToScrollTo != null && isViewDescendantOf(mChildToScrollTo, this)) { + scrollToChild(mChildToScrollTo); + } + mChildToScrollTo = null; + + if (!mIsLaidOut) { + // If there is a saved state, scroll to the position saved in that state. + if (mSavedState != null) { + scrollTo(getScrollX(), mSavedState.scrollPosition); + mSavedState = null; + } // mScrollY default value is "0" + + // Make sure current scrollY position falls into the scroll range. If it doesn't, + // scroll such that it does. + int childSize = 0; + if (getChildCount() > 0) { + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + childSize = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin; + } + int parentSpace = b - t - getPaddingTop() - getPaddingBottom(); + int currentScrollY = getScrollY(); + int newScrollY = clamp(currentScrollY, parentSpace, childSize); + if (newScrollY != currentScrollY) { + scrollTo(getScrollX(), newScrollY); + } + } + + // Calling this with the present values causes it to re-claim them + scrollTo(getScrollX(), getScrollY()); + mIsLaidOut = true; + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + mIsLaidOut = false; + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + View currentFocused = findFocus(); + if (null == currentFocused || this == currentFocused) { + return; + } + + // If the currently-focused view was visible on the screen when the + // screen was at the old height, then scroll the screen to make that + // view visible with the new screen height. + if (isWithinDeltaOfScreen(currentFocused, 0, oldh)) { + currentFocused.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(currentFocused, mTempRect); + int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect); + doScrollY(scrollDelta); + } + } + + /** + * Return true if child is a descendant of parent, (or equal to the parent). + */ + private static boolean isViewDescendantOf(View child, View parent) { + if (child == parent) { + return true; + } + + final ViewParent theParent = child.getParent(); + return (theParent instanceof ViewGroup) && isViewDescendantOf((View) theParent, parent); + } + + /** + * Fling the scroll view + * + * @param velocityY The initial velocity in the Y direction. Positive + * numbers mean that the finger/cursor is moving down the screen, + * which means we want to scroll towards the top. + */ + public void fling(int velocityY) { + if (getChildCount() > 0) { + + mScroller.fling(getScrollX(), getScrollY(), // start + 0, velocityY, // velocities + 0, 0, // x + Integer.MIN_VALUE, Integer.MAX_VALUE, // y + 0, 0); // overscroll + runAnimatedScroll(true); + } + } + + /** + * {@inheritDoc} + * + * <p>This version also clamps the scrolling to the bounds of our child. + */ + @Override + public void scrollTo(int x, int y) { + // we rely on the fact the View.scrollBy calls scrollTo. + if (getChildCount() > 0) { + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int parentSpaceHorizontal = getWidth() - getPaddingLeft() - getPaddingRight(); + int childSizeHorizontal = child.getWidth() + lp.leftMargin + lp.rightMargin; + int parentSpaceVertical = getHeight() - getPaddingTop() - getPaddingBottom(); + int childSizeVertical = child.getHeight() + lp.topMargin + lp.bottomMargin; + x = clamp(x, parentSpaceHorizontal, childSizeHorizontal); + y = clamp(y, parentSpaceVertical, childSizeVertical); + if (x != getScrollX() || y != getScrollY()) { + super.scrollTo(x, y); + } + } + } + + @Override + public void draw(@NonNull Canvas canvas) { + super.draw(canvas); + final int scrollY = getScrollY(); + if (!mEdgeGlowTop.isFinished()) { + final int restoreCount = canvas.save(); + int width = getWidth(); + int height = getHeight(); + int xTranslation = 0; + int yTranslation = Math.min(0, scrollY); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP + || Api21Impl.getClipToPadding(this)) { + width -= getPaddingLeft() + getPaddingRight(); + xTranslation += getPaddingLeft(); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && Api21Impl.getClipToPadding(this)) { + height -= getPaddingTop() + getPaddingBottom(); + yTranslation += getPaddingTop(); + } + canvas.translate(xTranslation, yTranslation); + mEdgeGlowTop.setSize(width, height); + if (mEdgeGlowTop.draw(canvas)) { + postInvalidateOnAnimation(); + } + canvas.restoreToCount(restoreCount); + } + if (!mEdgeGlowBottom.isFinished()) { + final int restoreCount = canvas.save(); + int width = getWidth(); + int height = getHeight(); + int xTranslation = 0; + int yTranslation = Math.max(getScrollRange(), scrollY) + height; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP + || Api21Impl.getClipToPadding(this)) { + width -= getPaddingLeft() + getPaddingRight(); + xTranslation += getPaddingLeft(); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && Api21Impl.getClipToPadding(this)) { + height -= getPaddingTop() + getPaddingBottom(); + yTranslation -= getPaddingBottom(); + } + canvas.translate(xTranslation - width, yTranslation); + canvas.rotate(180, width, 0); + mEdgeGlowBottom.setSize(width, height); + if (mEdgeGlowBottom.draw(canvas)) { + postInvalidateOnAnimation(); + } + canvas.restoreToCount(restoreCount); + } + } + + private static int clamp(int n, int my, int child) { + if (my >= child || n < 0) { + /* my >= child is this case: + * |--------------- me ---------------| + * |------ child ------| + * or + * |--------------- me ---------------| + * |------ child ------| + * or + * |--------------- me ---------------| + * |------ child ------| + * + * n < 0 is this case: + * |------ me ------| + * |-------- child --------| + * |-- mScrollX --| + */ + return 0; + } + if ((my + n) > child) { + /* this case: + * |------ me ------| + * |------ child ------| + * |-- mScrollX --| + */ + return child - my; + } + return n; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + mSavedState = ss; + requestLayout(); + } + + @NonNull + @Override + protected Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + SavedState ss = new SavedState(superState); + ss.scrollPosition = getScrollY(); + return ss; + } + + static class SavedState extends BaseSavedState { + public int scrollPosition; + + SavedState(Parcelable superState) { + super(superState); + } + + SavedState(Parcel source) { + super(source); + scrollPosition = source.readInt(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeInt(scrollPosition); + } + + @NonNull + @Override + public String toString() { + return "HorizontalScrollView.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " scrollPosition=" + scrollPosition + "}"; + } + + public static final Creator<SavedState> CREATOR = + new Creator<SavedState>() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + static class AccessibilityDelegate extends AccessibilityDelegateCompat { + @Override + public boolean performAccessibilityAction(View host, int action, Bundle arguments) { + if (super.performAccessibilityAction(host, action, arguments)) { + return true; + } + final NestedScrollView nsvHost = (NestedScrollView) host; + if (!nsvHost.isEnabled()) { + return false; + } + int height = nsvHost.getHeight(); + Rect rect = new Rect(); + // Gets the visible rect on the screen except for the rotation or scale cases which + // might affect the result. + if (nsvHost.getMatrix().isIdentity() && nsvHost.getGlobalVisibleRect(rect)) { + height = rect.height(); + } + switch (action) { + case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD: + case android.R.id.accessibilityActionScrollDown: { + final int viewportHeight = height - nsvHost.getPaddingBottom() + - nsvHost.getPaddingTop(); + final int targetScrollY = Math.min(nsvHost.getScrollY() + viewportHeight, + nsvHost.getScrollRange()); + if (targetScrollY != nsvHost.getScrollY()) { + nsvHost.smoothScrollTo(0, targetScrollY, true); + return true; + } + } + return false; + case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD: + case android.R.id.accessibilityActionScrollUp: { + final int viewportHeight = height - nsvHost.getPaddingBottom() + - nsvHost.getPaddingTop(); + final int targetScrollY = Math.max(nsvHost.getScrollY() - viewportHeight, 0); + if (targetScrollY != nsvHost.getScrollY()) { + nsvHost.smoothScrollTo(0, targetScrollY, true); + return true; + } + } + return false; + } + return false; + } + + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + final NestedScrollView nsvHost = (NestedScrollView) host; + info.setClassName(ScrollView.class.getName()); + if (nsvHost.isEnabled()) { + final int scrollRange = nsvHost.getScrollRange(); + if (scrollRange > 0) { + info.setScrollable(true); + if (nsvHost.getScrollY() > 0) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat + .ACTION_SCROLL_BACKWARD); + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat + .ACTION_SCROLL_UP); + } + if (nsvHost.getScrollY() < scrollRange) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat + .ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat + .ACTION_SCROLL_DOWN); + } + } + } + } + + @Override + public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(host, event); + final NestedScrollView nsvHost = (NestedScrollView) host; + event.setClassName(ScrollView.class.getName()); + final boolean scrollable = nsvHost.getScrollRange() > 0; + event.setScrollable(scrollable); + event.setScrollX(nsvHost.getScrollX()); + event.setScrollY(nsvHost.getScrollY()); + AccessibilityRecordCompat.setMaxScrollX(event, nsvHost.getScrollX()); + AccessibilityRecordCompat.setMaxScrollY(event, nsvHost.getScrollRange()); + } + } + + class DifferentialMotionFlingTargetImpl implements DifferentialMotionFlingTarget { + @Override + public boolean startDifferentialMotionFling(float velocity) { + if (velocity == 0) { + return false; + } + stopDifferentialMotionFling(); + fling((int) velocity); + return true; + } + + @Override + public void stopDifferentialMotionFling() { + mScroller.abortAnimation(); + } + + @Override + public float getScaledScrollFactor() { + return -getVerticalScrollFactorCompat(); + } + } + + @RequiresApi(21) + static class Api21Impl { + private Api21Impl() { + // This class is not instantiable. + } + + @DoNotInline + static boolean getClipToPadding(ViewGroup viewGroup) { + return viewGroup.getClipToPadding(); + } + } +} diff --git a/java/src/com/android/intentresolver/widget/NestedScrollView.java.patch b/java/src/com/android/intentresolver/widget/NestedScrollView.java.patch new file mode 100644 index 00000000..913d3b1a --- /dev/null +++ b/java/src/com/android/intentresolver/widget/NestedScrollView.java.patch @@ -0,0 +1,103 @@ +--- prebuilts/sdk/current/androidx/m2repository/androidx/core/core/1.13.0-beta01/core-1.13.0-beta01-sources.jar!/androidx/core/widget/NestedScrollView.java 1980-02-01 00:00:00.000000000 -0800 ++++ packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/NestedScrollView.java 2024-03-04 17:17:47.357059016 -0800 +@@ -1,5 +1,5 @@ + /* +- * Copyright (C) 2015 The Android Open Source Project ++ * 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. +@@ -15,10 +15,9 @@ + */ + + +-package androidx.core.widget; ++package com.android.intentresolver.widget; + + import static androidx.annotation.RestrictTo.Scope.LIBRARY; +-import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; + + import android.content.Context; + import android.content.res.TypedArray; +@@ -67,13 +66,19 @@ + import androidx.core.view.ViewCompat; + import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; + import androidx.core.view.accessibility.AccessibilityRecordCompat; ++import androidx.core.widget.EdgeEffectCompat; + + import java.util.List; + + /** +- * NestedScrollView is just like {@link ScrollView}, but it supports acting +- * as both a nested scrolling parent and child on both new and old versions of Android. +- * Nested scrolling is enabled by default. ++ * A copy of the {@link androidx.core.widget.NestedScrollView} (from ++ * prebuilts/sdk/current/androidx/m2repository/androidx/core/core/1.13.0-beta01/core-1.13.0-beta01-sources.jar) ++ * without any functional changes with a pure refactoring of {@link #requestChildFocus(View, View)}: ++ * the method's body is extracted into the new protected method, ++ * {@link #onRequestChildFocus(View, View)}. ++ * <p> ++ * For the exact change see NestedScrollView.java.patch file. ++ * </p> + */ + public class NestedScrollView extends FrameLayout implements NestedScrollingParent3, + NestedScrollingChild3, ScrollingView { +@@ -1858,7 +1863,6 @@ + * <p>The scroll range of a scroll view is the overall height of all of its + * children.</p> + */ +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeVerticalScrollRange() { + final int count = getChildCount(); +@@ -1881,31 +1885,26 @@ + return scrollRange; + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeVerticalScrollOffset() { + return Math.max(0, super.computeVerticalScrollOffset()); + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeVerticalScrollExtent() { + return super.computeVerticalScrollExtent(); + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeHorizontalScrollRange() { + return super.computeHorizontalScrollRange(); + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeHorizontalScrollOffset() { + return super.computeHorizontalScrollOffset(); + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeHorizontalScrollExtent() { + return super.computeHorizontalScrollExtent(); +@@ -2163,13 +2162,17 @@ + + @Override + public void requestChildFocus(View child, View focused) { ++ onRequestChildFocus(child, focused); ++ super.requestChildFocus(child, focused); ++ } ++ ++ protected void onRequestChildFocus(View child, View focused) { + if (!mIsLayoutDirty) { + scrollToChild(focused); + } else { + // The child may not be laid out yet, we can't compute the scroll yet + mChildToScrollTo = focused; + } +- super.requestChildFocus(child, focused); + } + + diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index 2c8140d9..07693b25 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -61,7 +61,7 @@ public class ResolverDrawerLayout extends ViewGroup { /** * Max width of the whole drawer layout */ - private final int mMaxWidth; + private int mMaxWidth; /** * Max total visible height of views not marked always-show when in the closed/initial state @@ -264,6 +264,16 @@ public class ResolverDrawerLayout extends ViewGroup { invalidate(); } + /** + * Sets max drawer width. + */ + public void setMaxWidth(int maxWidth) { + if (mMaxWidth != maxWidth) { + mMaxWidth = maxWidth; + requestLayout(); + } + } + public void setDismissLocked(boolean locked) { mDismissLocked = locked; } diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 7fe16091..c706e3ee 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -22,6 +22,7 @@ import android.graphics.Rect import android.net.Uri import android.util.AttributeSet import android.util.PluralsMessageFormatter +import android.util.Size import android.util.TypedValue import android.view.LayoutInflater import android.view.View @@ -60,11 +61,13 @@ private const val MIN_ASPECT_RATIO_STRING = "2:5" private const val MAX_ASPECT_RATIO = 2.5f private const val MAX_ASPECT_RATIO_STRING = "5:2" -private typealias CachingImageLoader = suspend (Uri, Boolean) -> Bitmap? +private typealias CachingImageLoader = suspend (Uri, Size, Boolean) -> Bitmap? class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor( context: Context, attrs: AttributeSet?, @@ -121,12 +124,19 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { * A hint about the maximum width this view can grow to, this helps to optimize preview loading. */ var maxWidthHint: Int = -1 + private var requestedHeight: Int = 0 private var isMeasured = false private var maxAspectRatio = MAX_ASPECT_RATIO private var maxAspectRatioString = MAX_ASPECT_RATIO_STRING private var outerSpacing: Int = 0 + var previewHeight: Int + get() = previewAdapter.previewHeight + set(value) { + previewAdapter.previewHeight = value + } + override fun onMeasure(widthSpec: Int, heightSpec: Int) { super.onMeasure(widthSpec, heightSpec) if (!isMeasured) { @@ -198,6 +208,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { BatchPreviewLoader( previewAdapter.imageLoader ?: error("Image loader is not set"), previews, + Size(previewHeight, previewHeight), totalItemCount, onUpdate = previewAdapter::addPreviews, onCompletion = { @@ -303,11 +314,19 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { private var isLoading = false private val hasOtherItem get() = previews.size < totalItemCount + val hasPreviews: Boolean get() = previews.isNotEmpty() var transitionStatusElementCallback: TransitionElementStatusCallback? = null + private var previewSize: Size = Size(0, 0) + var previewHeight: Int + get() = previewSize.height + set(value) { + previewSize = Size(value, value) + } + fun reset(totalItemCount: Int) { firstImagePos = -1 previews.clear() @@ -387,6 +406,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { vh.bind( previews[position], imageLoader ?: error("ImageLoader is missing"), + previewSize, fadeInDurationMs, isSharedTransitionElement = position == firstImagePos, previewReadyCallback = @@ -438,6 +458,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { fun bind( preview: Preview, imageLoader: CachingImageLoader, + previewSize: Size, fadeInDurationMs: Long, isSharedTransitionElement: Boolean, previewReadyCallback: ((String) -> Unit)? @@ -477,7 +498,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } resetScope().launch { - loadImage(preview, imageLoader) + loadImage(preview, previewSize, imageLoader) if (preview.type == PreviewType.Image && previewReadyCallback != null) { image.waitForPreDraw() previewReadyCallback(TRANSITION_NAME) @@ -487,12 +508,16 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - private suspend fun loadImage(preview: Preview, imageLoader: CachingImageLoader) { + private suspend fun loadImage( + preview: Preview, + previewSize: Size, + imageLoader: CachingImageLoader, + ) { val bitmap = runCatching { // it's expected for all loading/caching optimizations to be implemented by // the loader - imageLoader(preview.uri, true) + imageLoader(preview.uri, previewSize, true) } .getOrNull() image.setImageBitmap(bitmap) @@ -507,6 +532,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { setAnimationListener( object : AnimationListener { override fun onAnimationStart(animation: Animation?) = Unit + override fun onAnimationRepeat(animation: Animation?) = Unit override fun onAnimationEnd(animation: Animation?) { @@ -551,6 +577,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { private class LoadingItemViewHolder(view: View) : ViewHolder(view) { fun bind() = Unit + override fun unbind() = Unit } @@ -638,6 +665,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { class BatchPreviewLoader( private val imageLoader: CachingImageLoader, private val previews: Flow<Preview>, + private val previewSize: Size, val totalItemCount: Int, private val onUpdate: (List<Preview>) -> Unit, private val onCompletion: () -> Unit, @@ -701,10 +729,10 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { // imagine is one of the first images never loads so we never // fill the initial viewport and does not show the previews at // all. - imageLoader(preview.uri, isFirstBlock)?.let { bitmap -> + imageLoader(preview.uri, previewSize, isFirstBlock)?.let { + bitmap -> previewSizeUpdater(preview, bitmap.width, bitmap.height) - } - ?: 0 + } ?: 0 } .getOrDefault(0) |